mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
Merge remote-tracking branch 'upstream/dev' into feat/replace-logs
This commit is contained in:
commit
7a92ecc1ab
56 changed files with 2451 additions and 808 deletions
|
|
@ -165,6 +165,8 @@ COPY --from=frontend-builder /app/.next/standalone ./
|
|||
COPY --from=frontend-builder /app/.next/static ./.next/static
|
||||
COPY --from=frontend-builder /app/public ./public
|
||||
|
||||
COPY surfsense_web/content/docs /app/surfsense_web/content/docs
|
||||
|
||||
# ====================
|
||||
# Setup Backend
|
||||
# ====================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
"""Add Surfsense docs tables for global documentation storage
|
||||
|
||||
Revision ID: 60
|
||||
Revises: 59
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
from app.config import config
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "60"
|
||||
down_revision: str | None = "59"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# Get embedding dimension from config
|
||||
EMBEDDING_DIM = config.embedding_model_instance.dimension
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create surfsense_docs_documents and surfsense_docs_chunks tables."""
|
||||
|
||||
# Create surfsense_docs_documents table
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'surfsense_docs_documents'
|
||||
) THEN
|
||||
CREATE TABLE surfsense_docs_documents (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
source VARCHAR NOT NULL UNIQUE,
|
||||
title VARCHAR NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
content_hash VARCHAR NOT NULL,
|
||||
embedding vector({EMBEDDING_DIM}),
|
||||
updated_at TIMESTAMP WITH TIME ZONE
|
||||
);
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Create indexes for surfsense_docs_documents
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'surfsense_docs_documents' AND indexname = 'ix_surfsense_docs_documents_source'
|
||||
) THEN
|
||||
CREATE INDEX ix_surfsense_docs_documents_source ON surfsense_docs_documents(source);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'surfsense_docs_documents' AND indexname = 'ix_surfsense_docs_documents_content_hash'
|
||||
) THEN
|
||||
CREATE INDEX ix_surfsense_docs_documents_content_hash ON surfsense_docs_documents(content_hash);
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'surfsense_docs_documents' AND indexname = 'ix_surfsense_docs_documents_updated_at'
|
||||
) THEN
|
||||
CREATE INDEX ix_surfsense_docs_documents_updated_at ON surfsense_docs_documents(updated_at);
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Create surfsense_docs_chunks table
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_name = 'surfsense_docs_chunks'
|
||||
) THEN
|
||||
CREATE TABLE surfsense_docs_chunks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||||
content TEXT NOT NULL,
|
||||
embedding vector({EMBEDDING_DIM}),
|
||||
document_id INTEGER NOT NULL REFERENCES surfsense_docs_documents(id) ON DELETE CASCADE
|
||||
);
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Create indexes for surfsense_docs_chunks
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'surfsense_docs_chunks' AND indexname = 'ix_surfsense_docs_chunks_document_id'
|
||||
) THEN
|
||||
CREATE INDEX ix_surfsense_docs_chunks_document_id ON surfsense_docs_chunks(document_id);
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Create vector indexes for similarity search
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS surfsense_docs_documents_vector_index
|
||||
ON surfsense_docs_documents USING hnsw (embedding public.vector_cosine_ops);
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS surfsense_docs_chunks_vector_index
|
||||
ON surfsense_docs_chunks USING hnsw (embedding public.vector_cosine_ops);
|
||||
"""
|
||||
)
|
||||
|
||||
# Create full-text search indexes (same pattern as documents/chunks tables)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS surfsense_docs_documents_search_index
|
||||
ON surfsense_docs_documents USING gin (to_tsvector('english', content));
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS surfsense_docs_chunks_search_index
|
||||
ON surfsense_docs_chunks USING gin (to_tsvector('english', content));
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove surfsense docs tables."""
|
||||
# Drop full-text search indexes
|
||||
op.execute("DROP INDEX IF EXISTS surfsense_docs_chunks_search_index")
|
||||
op.execute("DROP INDEX IF EXISTS surfsense_docs_documents_search_index")
|
||||
|
||||
# Drop vector indexes
|
||||
op.execute("DROP INDEX IF EXISTS surfsense_docs_chunks_vector_index")
|
||||
op.execute("DROP INDEX IF EXISTS surfsense_docs_documents_vector_index")
|
||||
|
||||
# Drop regular indexes
|
||||
op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_chunks_document_id")
|
||||
op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_documents_updated_at")
|
||||
op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_documents_content_hash")
|
||||
op.execute("DROP INDEX IF EXISTS ix_surfsense_docs_documents_source")
|
||||
|
||||
# Drop tables (chunks first due to FK)
|
||||
op.execute("DROP TABLE IF EXISTS surfsense_docs_chunks")
|
||||
op.execute("DROP TABLE IF EXISTS surfsense_docs_documents")
|
||||
|
|
@ -26,6 +26,13 @@ SURFSENSE_TOOLS_INSTRUCTIONS = """
|
|||
<tools>
|
||||
You have access to the following tools:
|
||||
|
||||
0. search_surfsense_docs: Search the official SurfSense documentation.
|
||||
- Use this tool when the user asks anything about SurfSense itself (the application they are using).
|
||||
- Args:
|
||||
- query: The search query about SurfSense
|
||||
- top_k: Number of documentation chunks to retrieve (default: 10)
|
||||
- Returns: Documentation content with chunk IDs for citations (prefixed with 'doc-', e.g., [citation:doc-123])
|
||||
|
||||
1. search_knowledge_base: Search the user's personal knowledge base for relevant information.
|
||||
- Args:
|
||||
- query: The search query - be specific and include key terms
|
||||
|
|
@ -152,6 +159,18 @@ You have access to the following tools:
|
|||
- Airtable/Notion: Check field values, apply mapping above
|
||||
</tools>
|
||||
<tool_call_examples>
|
||||
- User: "How do I install SurfSense?"
|
||||
- Call: `search_surfsense_docs(query="installation setup")`
|
||||
|
||||
- User: "What connectors does SurfSense support?"
|
||||
- Call: `search_surfsense_docs(query="available connectors integrations")`
|
||||
|
||||
- User: "How do I set up the Notion connector?"
|
||||
- Call: `search_surfsense_docs(query="Notion connector setup configuration")`
|
||||
|
||||
- User: "How do I use Docker to run SurfSense?"
|
||||
- Call: `search_surfsense_docs(query="Docker installation setup")`
|
||||
|
||||
- User: "Fetch all my notes and what's in them?"
|
||||
- Call: `search_knowledge_base(query="*", top_k=50, connectors_to_search=["NOTE"])`
|
||||
|
||||
|
|
@ -308,7 +327,7 @@ The documents you receive are structured like this:
|
|||
</document_content>
|
||||
</document>
|
||||
|
||||
IMPORTANT: You MUST cite using the chunk ids (e.g. 123, 124). Do NOT cite document_id.
|
||||
IMPORTANT: You MUST cite using the chunk ids (e.g. 123, 124, doc-45). Do NOT cite document_id.
|
||||
</document_structure_example>
|
||||
|
||||
<citation_format>
|
||||
|
|
@ -319,11 +338,13 @@ IMPORTANT: You MUST cite using the chunk ids (e.g. 123, 124). Do NOT cite docume
|
|||
- NEVER create your own citation format - use the exact chunk_id values from the documents in the [citation:chunk_id] format
|
||||
- NEVER format citations as clickable links or as markdown links like "([citation:5](https://example.com))". Always use plain square brackets only
|
||||
- NEVER make up chunk IDs if you are unsure about the chunk_id. It is better to omit the citation than to guess
|
||||
- Copy the EXACT chunk id from the XML - if it says `<chunk id='doc-123'>`, use [citation:doc-123]
|
||||
</citation_format>
|
||||
|
||||
<citation_examples>
|
||||
CORRECT citation formats:
|
||||
- [citation:5]
|
||||
- [citation:doc-123] (for Surfsense documentation chunks)
|
||||
- [citation:chunk_id1], [citation:chunk_id2], [citation:chunk_id3]
|
||||
|
||||
INCORRECT citation formats (DO NOT use):
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ To add a new tool, see the documentation in registry.py.
|
|||
|
||||
Available tools:
|
||||
- search_knowledge_base: Search the user's personal knowledge base
|
||||
- search_surfsense_docs: Search Surfsense documentation for usage help
|
||||
- generate_podcast: Generate audio podcasts from content
|
||||
- link_preview: Fetch rich previews for URLs
|
||||
- display_image: Display images in chat
|
||||
|
|
@ -31,6 +32,7 @@ from .registry import (
|
|||
get_tool_by_name,
|
||||
)
|
||||
from .scrape_webpage import create_scrape_webpage_tool
|
||||
from .search_surfsense_docs import create_search_surfsense_docs_tool
|
||||
|
||||
__all__ = [
|
||||
# Registry
|
||||
|
|
@ -43,6 +45,7 @@ __all__ = [
|
|||
"create_link_preview_tool",
|
||||
"create_scrape_webpage_tool",
|
||||
"create_search_knowledge_base_tool",
|
||||
"create_search_surfsense_docs_tool",
|
||||
# Knowledge base utilities
|
||||
"format_documents_for_context",
|
||||
"get_all_tool_names",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ from .knowledge_base import create_search_knowledge_base_tool
|
|||
from .link_preview import create_link_preview_tool
|
||||
from .podcast import create_generate_podcast_tool
|
||||
from .scrape_webpage import create_scrape_webpage_tool
|
||||
from .search_surfsense_docs import create_search_surfsense_docs_tool
|
||||
|
||||
# =============================================================================
|
||||
# Tool Definition
|
||||
|
|
@ -126,6 +127,15 @@ BUILTIN_TOOLS: list[ToolDefinition] = [
|
|||
requires=[], # firecrawl_api_key is optional
|
||||
),
|
||||
# Note: write_todos is now provided by TodoListMiddleware from deepagents
|
||||
# Surfsense documentation search tool
|
||||
ToolDefinition(
|
||||
name="search_surfsense_docs",
|
||||
description="Search Surfsense documentation for help with using the application",
|
||||
factory=lambda deps: create_search_surfsense_docs_tool(
|
||||
db_session=deps["db_session"],
|
||||
),
|
||||
requires=["db_session"],
|
||||
),
|
||||
# =========================================================================
|
||||
# ADD YOUR CUSTOM TOOLS BELOW
|
||||
# =========================================================================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,163 @@
|
|||
"""
|
||||
Surfsense documentation search tool.
|
||||
|
||||
This tool allows the agent to search the pre-indexed Surfsense documentation
|
||||
to help users with questions about how to use the application.
|
||||
|
||||
The documentation is indexed at deployment time from MDX files and stored
|
||||
in dedicated tables (surfsense_docs_documents, surfsense_docs_chunks).
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from langchain_core.tools import tool
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import config
|
||||
from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument
|
||||
|
||||
|
||||
def format_surfsense_docs_results(results: list[tuple]) -> str:
|
||||
"""
|
||||
Format search results into XML structure for the LLM context.
|
||||
|
||||
Uses the same XML structure as format_documents_for_context from knowledge_base.py
|
||||
but with 'doc-' prefix on chunk IDs. This allows:
|
||||
- LLM to use consistent [citation:doc-XXX] format
|
||||
- Frontend to detect 'doc-' prefix and route to surfsense docs endpoint
|
||||
|
||||
Args:
|
||||
results: List of (chunk, document) tuples from the database query
|
||||
|
||||
Returns:
|
||||
Formatted XML string with documentation content and citation-ready chunks
|
||||
"""
|
||||
if not results:
|
||||
return "No relevant Surfsense documentation found for your query."
|
||||
|
||||
# Group chunks by document
|
||||
grouped: dict[int, dict] = {}
|
||||
for chunk, doc in results:
|
||||
if doc.id not in grouped:
|
||||
grouped[doc.id] = {
|
||||
"document_id": f"doc-{doc.id}",
|
||||
"document_type": "SURFSENSE_DOCS",
|
||||
"title": doc.title,
|
||||
"url": doc.source,
|
||||
"metadata": {"source": doc.source},
|
||||
"chunks": [],
|
||||
}
|
||||
grouped[doc.id]["chunks"].append(
|
||||
{
|
||||
"chunk_id": f"doc-{chunk.id}",
|
||||
"content": chunk.content,
|
||||
}
|
||||
)
|
||||
|
||||
# Render XML matching format_documents_for_context structure
|
||||
parts: list[str] = []
|
||||
for g in grouped.values():
|
||||
metadata_json = json.dumps(g["metadata"], ensure_ascii=False)
|
||||
|
||||
parts.append("<document>")
|
||||
parts.append("<document_metadata>")
|
||||
parts.append(f" <document_id>{g['document_id']}</document_id>")
|
||||
parts.append(f" <document_type>{g['document_type']}</document_type>")
|
||||
parts.append(f" <title><![CDATA[{g['title']}]]></title>")
|
||||
parts.append(f" <url><![CDATA[{g['url']}]]></url>")
|
||||
parts.append(f" <metadata_json><![CDATA[{metadata_json}]]></metadata_json>")
|
||||
parts.append("</document_metadata>")
|
||||
parts.append("")
|
||||
parts.append("<document_content>")
|
||||
|
||||
for ch in g["chunks"]:
|
||||
parts.append(
|
||||
f" <chunk id='{ch['chunk_id']}'><![CDATA[{ch['content']}]]></chunk>"
|
||||
)
|
||||
|
||||
parts.append("</document_content>")
|
||||
parts.append("</document>")
|
||||
parts.append("")
|
||||
|
||||
return "\n".join(parts).strip()
|
||||
|
||||
|
||||
async def search_surfsense_docs_async(
|
||||
query: str,
|
||||
db_session: AsyncSession,
|
||||
top_k: int = 10,
|
||||
) -> str:
|
||||
"""
|
||||
Search Surfsense documentation using vector similarity.
|
||||
|
||||
Args:
|
||||
query: The search query about Surfsense usage
|
||||
db_session: Database session for executing queries
|
||||
top_k: Number of results to return
|
||||
|
||||
Returns:
|
||||
Formatted string with relevant documentation content
|
||||
"""
|
||||
# Get embedding for the query
|
||||
query_embedding = config.embedding_model_instance.embed(query)
|
||||
|
||||
# Vector similarity search on chunks, joining with documents
|
||||
stmt = (
|
||||
select(SurfsenseDocsChunk, SurfsenseDocsDocument)
|
||||
.join(
|
||||
SurfsenseDocsDocument,
|
||||
SurfsenseDocsChunk.document_id == SurfsenseDocsDocument.id,
|
||||
)
|
||||
.order_by(SurfsenseDocsChunk.embedding.op("<=>")(query_embedding))
|
||||
.limit(top_k)
|
||||
)
|
||||
|
||||
result = await db_session.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
return format_surfsense_docs_results(rows)
|
||||
|
||||
|
||||
def create_search_surfsense_docs_tool(db_session: AsyncSession):
|
||||
"""
|
||||
Factory function to create the search_surfsense_docs tool.
|
||||
|
||||
Args:
|
||||
db_session: Database session for executing queries
|
||||
|
||||
Returns:
|
||||
A configured tool function for searching Surfsense documentation
|
||||
"""
|
||||
|
||||
@tool
|
||||
async def search_surfsense_docs(query: str, top_k: int = 10) -> str:
|
||||
"""
|
||||
Search Surfsense documentation for help with using the application.
|
||||
|
||||
Use this tool when the user asks questions about:
|
||||
- How to use Surfsense features
|
||||
- Installation and setup instructions
|
||||
- Configuration options and settings
|
||||
- Troubleshooting common issues
|
||||
- Available connectors and integrations
|
||||
- Browser extension usage
|
||||
- API documentation
|
||||
|
||||
This searches the official Surfsense documentation that was indexed
|
||||
at deployment time. It does NOT search the user's personal knowledge base.
|
||||
|
||||
Args:
|
||||
query: The search query about Surfsense usage or features
|
||||
top_k: Number of documentation chunks to retrieve (default: 10)
|
||||
|
||||
Returns:
|
||||
Relevant documentation content formatted with chunk IDs for citations
|
||||
"""
|
||||
return await search_surfsense_docs_async(
|
||||
query=query,
|
||||
db_session=db_session,
|
||||
top_k=top_k,
|
||||
)
|
||||
|
||||
return search_surfsense_docs
|
||||
|
|
@ -13,6 +13,7 @@ from app.config import config
|
|||
from app.db import User, create_db_and_tables, get_async_session
|
||||
from app.routes import router as crud_router
|
||||
from app.schemas import UserCreate, UserRead, UserUpdate
|
||||
from app.tasks.surfsense_docs_indexer import seed_surfsense_docs
|
||||
from app.users import SECRET, auth_backend, current_active_user, fastapi_users
|
||||
|
||||
|
||||
|
|
@ -22,6 +23,8 @@ async def lifespan(app: FastAPI):
|
|||
await create_db_and_tables()
|
||||
# Setup LangGraph checkpointer tables for conversation persistence
|
||||
await setup_checkpointer_tables()
|
||||
# Seed Surfsense documentation
|
||||
await seed_surfsense_docs()
|
||||
yield
|
||||
# Cleanup: close checkpointer connection on shutdown
|
||||
await close_checkpointer()
|
||||
|
|
|
|||
|
|
@ -428,6 +428,46 @@ class Chunk(BaseModel, TimestampMixin):
|
|||
document = relationship("Document", back_populates="chunks")
|
||||
|
||||
|
||||
class SurfsenseDocsDocument(BaseModel, TimestampMixin):
|
||||
"""
|
||||
Surfsense documentation storage.
|
||||
Indexed at migration time from MDX files.
|
||||
"""
|
||||
|
||||
__tablename__ = "surfsense_docs_documents"
|
||||
|
||||
source = Column(
|
||||
String, nullable=False, unique=True, index=True
|
||||
) # File path: "connectors/slack.mdx"
|
||||
title = Column(String, nullable=False)
|
||||
content = Column(Text, nullable=False)
|
||||
content_hash = Column(String, nullable=False, index=True) # For detecting changes
|
||||
embedding = Column(Vector(config.embedding_model_instance.dimension))
|
||||
updated_at = Column(TIMESTAMP(timezone=True), nullable=True, index=True)
|
||||
|
||||
chunks = relationship(
|
||||
"SurfsenseDocsChunk",
|
||||
back_populates="document",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
|
||||
class SurfsenseDocsChunk(BaseModel, TimestampMixin):
|
||||
"""Chunk storage for Surfsense documentation."""
|
||||
|
||||
__tablename__ = "surfsense_docs_chunks"
|
||||
|
||||
content = Column(Text, nullable=False)
|
||||
embedding = Column(Vector(config.embedding_model_instance.dimension))
|
||||
|
||||
document_id = Column(
|
||||
Integer,
|
||||
ForeignKey("surfsense_docs_documents.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
document = relationship("SurfsenseDocsDocument", back_populates="chunks")
|
||||
|
||||
|
||||
class Podcast(BaseModel, TimestampMixin):
|
||||
"""Podcast model for storing generated podcasts."""
|
||||
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ from .rbac_routes import router as rbac_router
|
|||
from .search_source_connectors_routes import router as search_source_connectors_router
|
||||
from .search_spaces_routes import router as search_spaces_router
|
||||
from .slack_add_connector_route import router as slack_add_connector_router
|
||||
from .surfsense_docs_routes import router as surfsense_docs_router
|
||||
from .teams_add_connector_route import router as teams_add_connector_router
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -59,3 +60,4 @@ router.include_router(clickup_add_connector_router)
|
|||
router.include_router(new_llm_config_router) # LLM configs with prompt configuration
|
||||
router.include_router(logs_router)
|
||||
router.include_router(circleback_webhook_router) # Circleback meeting webhooks
|
||||
router.include_router(surfsense_docs_router) # Surfsense documentation for citations
|
||||
|
|
|
|||
|
|
@ -624,10 +624,7 @@ async def index_connector_content(
|
|||
SearchSourceConnectorType.LUMA_CONNECTOR,
|
||||
]:
|
||||
# Default to today if no end_date provided (users can manually select future dates)
|
||||
if end_date is None:
|
||||
indexing_to = today_str
|
||||
else:
|
||||
indexing_to = end_date
|
||||
indexing_to = today_str if end_date is None else end_date
|
||||
else:
|
||||
# For non-calendar connectors, cap at today
|
||||
indexing_to = end_date if end_date else today_str
|
||||
|
|
|
|||
89
surfsense_backend/app/routes/surfsense_docs_routes.py
Normal file
89
surfsense_backend/app/routes/surfsense_docs_routes.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""
|
||||
Routes for Surfsense documentation.
|
||||
|
||||
These endpoints support the citation system for Surfsense docs,
|
||||
allowing the frontend to fetch document details when a user clicks
|
||||
on a [citation:doc-XXX] link.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.db import (
|
||||
SurfsenseDocsChunk,
|
||||
SurfsenseDocsDocument,
|
||||
User,
|
||||
get_async_session,
|
||||
)
|
||||
from app.schemas.surfsense_docs import (
|
||||
SurfsenseDocsChunkRead,
|
||||
SurfsenseDocsDocumentWithChunksRead,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/surfsense-docs/by-chunk/{chunk_id}",
|
||||
response_model=SurfsenseDocsDocumentWithChunksRead,
|
||||
)
|
||||
async def get_surfsense_doc_by_chunk_id(
|
||||
chunk_id: int,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
user: User = Depends(current_active_user),
|
||||
):
|
||||
"""
|
||||
Retrieves a Surfsense documentation document based on a chunk ID.
|
||||
|
||||
This endpoint is used by the frontend to resolve [citation:doc-XXX] links.
|
||||
"""
|
||||
try:
|
||||
# Get the chunk
|
||||
chunk_result = await session.execute(
|
||||
select(SurfsenseDocsChunk).filter(SurfsenseDocsChunk.id == chunk_id)
|
||||
)
|
||||
chunk = chunk_result.scalars().first()
|
||||
|
||||
if not chunk:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Surfsense docs chunk with id {chunk_id} not found",
|
||||
)
|
||||
|
||||
# Get the associated document with all its chunks
|
||||
document_result = await session.execute(
|
||||
select(SurfsenseDocsDocument)
|
||||
.options(selectinload(SurfsenseDocsDocument.chunks))
|
||||
.filter(SurfsenseDocsDocument.id == chunk.document_id)
|
||||
)
|
||||
document = document_result.scalars().first()
|
||||
|
||||
if not document:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Surfsense docs document not found",
|
||||
)
|
||||
|
||||
# Sort chunks by ID
|
||||
sorted_chunks = sorted(document.chunks, key=lambda x: x.id)
|
||||
|
||||
return SurfsenseDocsDocumentWithChunksRead(
|
||||
id=document.id,
|
||||
title=document.title,
|
||||
source=document.source,
|
||||
content=document.content,
|
||||
chunks=[
|
||||
SurfsenseDocsChunkRead(id=c.id, content=c.content)
|
||||
for c in sorted_chunks
|
||||
],
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve Surfsense documentation: {e!s}",
|
||||
) from e
|
||||
26
surfsense_backend/app/schemas/surfsense_docs.py
Normal file
26
surfsense_backend/app/schemas/surfsense_docs.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""
|
||||
Schemas for Surfsense documentation.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class SurfsenseDocsChunkRead(BaseModel):
|
||||
"""Schema for a Surfsense docs chunk."""
|
||||
|
||||
id: int
|
||||
content: str
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SurfsenseDocsDocumentWithChunksRead(BaseModel):
|
||||
"""Schema for a Surfsense docs document with its chunks."""
|
||||
|
||||
id: int
|
||||
title: str
|
||||
source: str
|
||||
content: str
|
||||
chunks: list[SurfsenseDocsChunkRead]
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
229
surfsense_backend/app/tasks/surfsense_docs_indexer.py
Normal file
229
surfsense_backend/app/tasks/surfsense_docs_indexer.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
"""
|
||||
Surfsense documentation indexer.
|
||||
Indexes MDX documentation files at startup.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import config
|
||||
from app.db import SurfsenseDocsChunk, SurfsenseDocsDocument, async_session_maker
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Path to docs relative to project root
|
||||
DOCS_DIR = (
|
||||
Path(__file__).resolve().parent.parent.parent.parent
|
||||
/ "surfsense_web"
|
||||
/ "content"
|
||||
/ "docs"
|
||||
)
|
||||
|
||||
|
||||
def parse_mdx_frontmatter(content: str) -> tuple[str, str]:
|
||||
"""
|
||||
Parse MDX file to extract frontmatter title and content.
|
||||
|
||||
Args:
|
||||
content: Raw MDX file content
|
||||
|
||||
Returns:
|
||||
Tuple of (title, content_without_frontmatter)
|
||||
"""
|
||||
# Match frontmatter between --- markers
|
||||
frontmatter_pattern = r"^---\s*\n(.*?)\n---\s*\n"
|
||||
match = re.match(frontmatter_pattern, content, re.DOTALL)
|
||||
|
||||
if match:
|
||||
frontmatter = match.group(1)
|
||||
content_without_frontmatter = content[match.end() :]
|
||||
|
||||
# Extract title from frontmatter
|
||||
title_match = re.search(r"^title:\s*(.+)$", frontmatter, re.MULTILINE)
|
||||
title = title_match.group(1).strip() if title_match else "Untitled"
|
||||
|
||||
# Remove quotes if present
|
||||
title = title.strip("\"'")
|
||||
|
||||
return title, content_without_frontmatter.strip()
|
||||
|
||||
return "Untitled", content.strip()
|
||||
|
||||
|
||||
def get_all_mdx_files() -> list[Path]:
|
||||
"""
|
||||
Get all MDX files from the docs directory.
|
||||
|
||||
Returns:
|
||||
List of Path objects for each MDX file
|
||||
"""
|
||||
if not DOCS_DIR.exists():
|
||||
logger.warning(f"Docs directory not found: {DOCS_DIR}")
|
||||
return []
|
||||
|
||||
return list(DOCS_DIR.rglob("*.mdx"))
|
||||
|
||||
|
||||
def generate_surfsense_docs_content_hash(content: str) -> str:
|
||||
"""Generate SHA-256 hash for Surfsense docs content."""
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def create_surfsense_docs_chunks(content: str) -> list[SurfsenseDocsChunk]:
|
||||
"""
|
||||
Create chunks from Surfsense documentation content.
|
||||
|
||||
Args:
|
||||
content: Document content to chunk
|
||||
|
||||
Returns:
|
||||
List of SurfsenseDocsChunk objects with embeddings
|
||||
"""
|
||||
return [
|
||||
SurfsenseDocsChunk(
|
||||
content=chunk.text,
|
||||
embedding=config.embedding_model_instance.embed(chunk.text),
|
||||
)
|
||||
for chunk in config.chunker_instance.chunk(content)
|
||||
]
|
||||
|
||||
|
||||
async def index_surfsense_docs(session: AsyncSession) -> tuple[int, int, int, int]:
|
||||
"""
|
||||
Index all Surfsense documentation files.
|
||||
|
||||
Args:
|
||||
session: SQLAlchemy async session
|
||||
|
||||
Returns:
|
||||
Tuple of (created, updated, skipped, deleted) counts
|
||||
"""
|
||||
created = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
deleted = 0
|
||||
|
||||
# Get all existing docs from database
|
||||
existing_docs_result = await session.execute(
|
||||
select(SurfsenseDocsDocument).options(
|
||||
selectinload(SurfsenseDocsDocument.chunks)
|
||||
)
|
||||
)
|
||||
existing_docs = {doc.source: doc for doc in existing_docs_result.scalars().all()}
|
||||
|
||||
# Track which sources we've processed
|
||||
processed_sources = set()
|
||||
|
||||
# Get all MDX files
|
||||
mdx_files = get_all_mdx_files()
|
||||
logger.info(f"Found {len(mdx_files)} MDX files to index")
|
||||
|
||||
for mdx_file in mdx_files:
|
||||
try:
|
||||
source = str(mdx_file.relative_to(DOCS_DIR))
|
||||
processed_sources.add(source)
|
||||
|
||||
# Read file content
|
||||
raw_content = mdx_file.read_text(encoding="utf-8")
|
||||
title, content = parse_mdx_frontmatter(raw_content)
|
||||
content_hash = generate_surfsense_docs_content_hash(raw_content)
|
||||
|
||||
if source in existing_docs:
|
||||
existing_doc = existing_docs[source]
|
||||
|
||||
# Check if content changed
|
||||
if existing_doc.content_hash == content_hash:
|
||||
logger.debug(f"Skipping unchanged: {source}")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Content changed - update document
|
||||
logger.info(f"Updating changed document: {source}")
|
||||
|
||||
# Create new chunks
|
||||
chunks = create_surfsense_docs_chunks(content)
|
||||
|
||||
# Update document fields
|
||||
existing_doc.title = title
|
||||
existing_doc.content = content
|
||||
existing_doc.content_hash = content_hash
|
||||
existing_doc.embedding = config.embedding_model_instance.embed(content)
|
||||
existing_doc.chunks = chunks
|
||||
existing_doc.updated_at = datetime.now(UTC)
|
||||
|
||||
updated += 1
|
||||
else:
|
||||
# New document - create it
|
||||
logger.info(f"Creating new document: {source}")
|
||||
|
||||
chunks = create_surfsense_docs_chunks(content)
|
||||
|
||||
document = SurfsenseDocsDocument(
|
||||
source=source,
|
||||
title=title,
|
||||
content=content,
|
||||
content_hash=content_hash,
|
||||
embedding=config.embedding_model_instance.embed(content),
|
||||
chunks=chunks,
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
|
||||
session.add(document)
|
||||
created += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {mdx_file}: {e}", exc_info=True)
|
||||
continue
|
||||
|
||||
# Delete documents for removed files
|
||||
for source, doc in existing_docs.items():
|
||||
if source not in processed_sources:
|
||||
logger.info(f"Deleting removed document: {source}")
|
||||
await session.delete(doc)
|
||||
deleted += 1
|
||||
|
||||
# Commit all changes
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
f"Indexing complete: {created} created, {updated} updated, "
|
||||
f"{skipped} skipped, {deleted} deleted"
|
||||
)
|
||||
|
||||
return created, updated, skipped, deleted
|
||||
|
||||
|
||||
async def seed_surfsense_docs() -> tuple[int, int, int, int]:
|
||||
"""
|
||||
Seed Surfsense documentation into the database.
|
||||
|
||||
This function indexes all MDX files from the docs directory.
|
||||
It handles creating, updating, and deleting docs based on content changes.
|
||||
|
||||
Returns:
|
||||
Tuple of (created, updated, skipped, deleted) counts
|
||||
Returns (0, 0, 0, 0) if an error occurs
|
||||
"""
|
||||
logger.info("Starting Surfsense docs indexing...")
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
created, updated, skipped, deleted = await index_surfsense_docs(session)
|
||||
|
||||
logger.info(
|
||||
f"Surfsense docs indexing complete: "
|
||||
f"created={created}, updated={updated}, skipped={skipped}, deleted={deleted}"
|
||||
)
|
||||
|
||||
return created, updated, skipped, deleted
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to seed Surfsense docs: {e}", exc_info=True)
|
||||
return 0, 0, 0, 0
|
||||
40
surfsense_backend/scripts/seed_surfsense_docs.py
Normal file
40
surfsense_backend/scripts/seed_surfsense_docs.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env python
|
||||
"""
|
||||
Seed Surfsense documentation into the database.
|
||||
|
||||
CLI wrapper for the seed_surfsense_docs function.
|
||||
Can be run manually for debugging or re-indexing.
|
||||
|
||||
Usage:
|
||||
python scripts/seed_surfsense_docs.py
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the parent directory to the path so we can import app modules
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from app.tasks.surfsense_docs_indexer import seed_surfsense_docs
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point for seeding Surfsense docs."""
|
||||
print("=" * 50)
|
||||
print(" Surfsense Documentation Seeding")
|
||||
print("=" * 50)
|
||||
|
||||
created, updated, skipped, deleted = asyncio.run(seed_surfsense_docs())
|
||||
|
||||
print()
|
||||
print("Results:")
|
||||
print(f" Created: {created}")
|
||||
print(f" Updated: {updated}")
|
||||
print(f" Skipped: {skipped}")
|
||||
print(f" Deleted: {deleted}")
|
||||
print("=" * 50)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -87,7 +87,8 @@ function SettingsSidebar({
|
|||
<aside
|
||||
className={cn(
|
||||
"fixed md:relative left-0 top-0 z-50 md:z-auto",
|
||||
"w-72 shrink-0 bg-background md:bg-muted/20 h-full flex flex-col",
|
||||
"w-72 shrink-0 bg-background md:bg-muted/30 h-full flex flex-col",
|
||||
"md:border-r",
|
||||
"transition-transform duration-300 ease-out",
|
||||
"md:translate-x-0",
|
||||
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||
|
|
@ -177,7 +178,7 @@ function SettingsSidebar({
|
|||
|
||||
{/* Footer */}
|
||||
<div className="p-4">
|
||||
<p className="text-xs text-muted-foreground text-center">SurfSense Settings</p>
|
||||
<p className="text-xs text-muted-foreground text-center">Search Space Settings</p>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
|
|
@ -286,20 +287,24 @@ export default function SettingsPage() {
|
|||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="h-full flex bg-background"
|
||||
className="fixed inset-0 z-50 flex bg-muted/40"
|
||||
>
|
||||
<SettingsSidebar
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
onBackToApp={handleBackToApp}
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
<SettingsContent
|
||||
activeSection={activeSection}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onMenuClick={() => setIsSidebarOpen(true)}
|
||||
/>
|
||||
<div className="flex h-full w-full p-0 md:p-2">
|
||||
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm">
|
||||
<SettingsSidebar
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
onBackToApp={handleBackToApp}
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
/>
|
||||
<SettingsContent
|
||||
activeSection={activeSection}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onMenuClick={() => setIsSidebarOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
Check,
|
||||
Clock,
|
||||
|
|
@ -27,7 +26,7 @@ import {
|
|||
Users,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
|
|
@ -144,7 +143,6 @@ const cardVariants = {
|
|||
};
|
||||
|
||||
export default function TeamManagementPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = Number(params.search_space_id);
|
||||
const [activeTab, setActiveTab] = useState("members");
|
||||
|
|
@ -334,14 +332,6 @@ export default function TeamManagementPage() {
|
|||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-start space-x-3 md:items-center md:space-x-4">
|
||||
<button
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}`)}
|
||||
className="flex items-center justify-center h-9 w-9 md:h-10 md:w-10 rounded-lg bg-primary/10 hover:bg-primary/20 transition-colors shrink-0"
|
||||
aria-label="Back to Dashboard"
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 md:h-5 md:w-5 text-primary" />
|
||||
</button>
|
||||
<div className="flex h-10 w-10 md:h-12 md:w-12 items-center justify-center rounded-xl bg-gradient-to-br from-primary/20 to-primary/5 ring-1 ring-primary/10 shrink-0">
|
||||
<Users className="h-5 w-5 md:h-6 md:w-6 text-primary" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,185 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useApiKey } from "@/hooks/use-api-key";
|
||||
|
||||
const fadeIn = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: { opacity: 1, transition: { duration: 0.4 } },
|
||||
};
|
||||
|
||||
const staggerContainer = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ApiKeyClient = () => {
|
||||
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="flex justify-center w-full min-h-screen py-10 px-4">
|
||||
<motion.div
|
||||
className="w-full max-w-3xl"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
<motion.div className="mb-8 text-center" variants={fadeIn}>
|
||||
<h1 className="text-3xl font-bold tracking-tight">API Key</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Your API key for authenticating with the SurfSense API.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeIn}>
|
||||
<Alert className="mb-8">
|
||||
<IconKey className="h-4 w-4" />
|
||||
<AlertTitle>Important</AlertTitle>
|
||||
<AlertDescription>
|
||||
Your API key grants full access to your account. Never share it publicly or with
|
||||
unauthorized users.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</motion.div>
|
||||
|
||||
<motion.div variants={fadeIn}>
|
||||
<Card>
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>Your API Key</CardTitle>
|
||||
<CardDescription>Use this key to authenticate your API requests.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<AnimatePresence mode="wait">
|
||||
{isLoading ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-10 w-full bg-muted animate-pulse rounded-md"
|
||||
/>
|
||||
) : apiKey ? (
|
||||
<motion.div
|
||||
key="api-key"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<div className="bg-muted p-3 rounded-md flex-1 font-mono text-sm overflow-x-auto whitespace-nowrap">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{apiKey}
|
||||
</motion.div>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyToClipboard}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<motion.div
|
||||
whileTap={{ scale: 0.9 }}
|
||||
animate={copied ? { scale: [1, 1.2, 1] } : {}}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck className="h-4 w-4" />
|
||||
) : (
|
||||
<IconCopy className="h-4 w-4" />
|
||||
)}
|
||||
</motion.div>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{copied ? "Copied!" : "Copy to clipboard"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="no-key"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="text-muted-foreground text-center"
|
||||
>
|
||||
No API key found.
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-8"
|
||||
variants={fadeIn}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<h2 className="text-xl font-semibold mb-4 text-center">How to use your API key</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<motion.div
|
||||
className="space-y-4"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
<motion.div variants={fadeIn}>
|
||||
<h3 className="font-medium mb-2 text-center">Authentication</h3>
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Include your API key in the Authorization header of your requests:
|
||||
</p>
|
||||
<motion.pre
|
||||
className="bg-muted p-3 rounded-md mt-2 overflow-x-auto"
|
||||
whileHover={{ scale: 1.01 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 10 }}
|
||||
>
|
||||
<code className="text-xs">
|
||||
Authorization: Bearer {apiKey || "YOUR_API_KEY"}
|
||||
</code>
|
||||
</motion.pre>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<div>
|
||||
<button
|
||||
onClick={() => router.push("/dashboard")}
|
||||
className="flex items-center justify-center h-10 w-10 rounded-lg bg-primary/10 hover:bg-primary/30 transition-colors"
|
||||
aria-label="Back to Dashboard"
|
||||
type="button"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeyClient;
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Loading component with animation
|
||||
const LoadingComponent = () => (
|
||||
<div className="flex flex-col justify-center items-center min-h-screen">
|
||||
<div className="w-16 h-16 border-4 border-primary border-t-transparent rounded-full animate-spin mb-4"></div>
|
||||
<p className="text-muted-foreground">Loading API Key Management...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Dynamically import the ApiKeyClient component
|
||||
const ApiKeyClient = dynamic(() => import("./api-key-client"), {
|
||||
ssr: false,
|
||||
loading: () => <LoadingComponent />,
|
||||
});
|
||||
|
||||
export default function ClientWrapper() {
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!isMounted) {
|
||||
return <LoadingComponent />;
|
||||
}
|
||||
|
||||
return <ApiKeyClient />;
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import ClientWrapper from "./client-wrapper";
|
||||
|
||||
export default function ApiKeyPage() {
|
||||
return <ClientWrapper />;
|
||||
}
|
||||
|
|
@ -1,32 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Loader2, Plus, Search, Trash2, UserCheck, Users } from "lucide-react";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { AlertCircle, Loader2, Plus, Search } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { useEffect, useState } from "react";
|
||||
import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms";
|
||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||
import { UserDropdown } from "@/components/UserDropdown";
|
||||
import { CreateSearchSpaceDialog } from "@/components/layout";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -36,29 +18,11 @@ import {
|
|||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Spotlight } from "@/components/ui/spotlight";
|
||||
import { Tilt } from "@/components/ui/tilt";
|
||||
|
||||
/**
|
||||
* Formats a date string into a readable format
|
||||
* @param dateString - The date string to format
|
||||
* @returns Formatted date string (e.g., "Jan 1, 2023")
|
||||
*/
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Loading screen component with animation
|
||||
*/
|
||||
const LoadingScreen = () => {
|
||||
function LoadingScreen() {
|
||||
const t = useTranslations("dashboard");
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
|
|
@ -72,7 +36,7 @@ const LoadingScreen = () => {
|
|||
<CardContent className="flex justify-center py-6">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1.5, repeat: Infinity, ease: "linear" }}
|
||||
transition={{ duration: 1.5, repeat: Number.POSITIVE_INFINITY, ease: "linear" }}
|
||||
>
|
||||
<Loader2 className="h-12 w-12 text-primary" />
|
||||
</motion.div>
|
||||
|
|
@ -84,23 +48,20 @@ const LoadingScreen = () => {
|
|||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Error screen component with animation
|
||||
*/
|
||||
const ErrorScreen = ({ message }: { message: string }) => {
|
||||
function ErrorScreen({ message }: { message: string }) {
|
||||
const t = useTranslations("dashboard");
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] space-y-4">
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<Card className="w-full max-w-[400px] bg-background/60 backdrop-blur-sm border-destructive/20">
|
||||
<Card className="w-full max-w-[400px] border-destructive/20 bg-background/60 backdrop-blur-sm">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-destructive" />
|
||||
|
|
@ -109,7 +70,7 @@ const ErrorScreen = ({ message }: { message: string }) => {
|
|||
<CardDescription>{t("something_wrong")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Alert variant="destructive" className="bg-destructive/10 border-destructive/30">
|
||||
<Alert variant="destructive" className="border-destructive/30 bg-destructive/10">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("error_details")}</AlertTitle>
|
||||
<AlertDescription className="mt-2">{message}</AlertDescription>
|
||||
|
|
@ -125,269 +86,62 @@ const ErrorScreen = ({ message }: { message: string }) => {
|
|||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const DashboardPage = () => {
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
|
||||
// Animation variants
|
||||
const containerVariants: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: { y: 20, opacity: 0 },
|
||||
visible: {
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 24,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const {
|
||||
data: searchSpaces = [],
|
||||
isLoading: loading,
|
||||
error,
|
||||
refetch: refreshSearchSpaces,
|
||||
} = useAtomValue(searchSpacesAtom);
|
||||
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
|
||||
|
||||
const { data: user, isPending: isLoadingUser, error: userError } = useAtomValue(currentUserAtom);
|
||||
|
||||
// Auto-redirect to chat for users with exactly 1 search space
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
if (searchSpaces.length === 1) {
|
||||
router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`);
|
||||
}
|
||||
}, [loading, searchSpaces, router]);
|
||||
|
||||
// Create user object for UserDropdown
|
||||
const customUser = {
|
||||
name: user?.email ? user.email.split("@")[0] : "User",
|
||||
email:
|
||||
user?.email ||
|
||||
(isLoadingUser ? "Loading..." : userError ? "Error loading user" : "Unknown User"),
|
||||
avatar: "/icon-128.svg", // Default avatar
|
||||
};
|
||||
|
||||
// Show loading while loading or auto-redirecting (single search space)
|
||||
if (loading || (searchSpaces.length === 1 && !error)) return <LoadingScreen />;
|
||||
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
|
||||
|
||||
const handleDeleteSearchSpace = async (id: number) => {
|
||||
await deleteSearchSpace({ id });
|
||||
refreshSearchSpaces();
|
||||
};
|
||||
function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
|
||||
const t = useTranslations("searchSpace");
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="container mx-auto py-6 md:py-10 px-4"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={containerVariants}
|
||||
>
|
||||
<motion.div className="flex flex-col space-y-4 md:space-y-6" variants={itemVariants}>
|
||||
<div className="flex flex-row items-center justify-between gap-2">
|
||||
<div className="flex flex-row items-center md:space-x-4">
|
||||
<Logo className="w-8 h-8 md:w-10 md:h-10 rounded-md shrink-0 hidden md:block" />
|
||||
<div className="flex flex-col space-y-0.5 md:space-y-2">
|
||||
<h1 className="text-xl md:text-4xl font-bold">{t("surfsense_dashboard")}</h1>
|
||||
<p className="text-sm md:text-base text-muted-foreground">{t("welcome_message")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 md:space-x-3 shrink-0">
|
||||
<UserDropdown user={customUser} />
|
||||
<ThemeTogglerComponent />
|
||||
</div>
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="flex flex-col items-center gap-6 text-center"
|
||||
>
|
||||
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10">
|
||||
<Search className="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col space-y-6 mt-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-lg md:text-2xl font-semibold">{t("your_search_spaces")}</h2>
|
||||
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
|
||||
<Link href="/dashboard/searchspaces">
|
||||
<Button className="h-8 md:h-10 text-[11px] md:text-sm px-3 md:px-4">
|
||||
<Plus className="mr-1 md:mr-2 h-3 w-3 md:h-4 md:w-4" />
|
||||
{t("create_search_space")}
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
|
||||
{searchSpaces &&
|
||||
searchSpaces.length > 0 &&
|
||||
searchSpaces.map((space) => (
|
||||
<motion.div key={space.id} variants={itemVariants} className="aspect-4/3">
|
||||
<Tilt
|
||||
rotationFactor={6}
|
||||
isRevese
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
className="group relative rounded-lg h-full"
|
||||
>
|
||||
<Spotlight
|
||||
className="z-10 from-blue-500/20 via-blue-300/10 to-blue-200/5 blur-2xl"
|
||||
size={248}
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col h-full justify-between overflow-hidden rounded-xl border bg-muted/30 backdrop-blur-sm transition-all hover:border-primary/50">
|
||||
<div className="relative h-32 w-full overflow-hidden">
|
||||
<Link href={`/dashboard/${space.id}/new-chat`} key={space.id}>
|
||||
<Image
|
||||
src="https://images.unsplash.com/photo-1519389950473-47ba0277781c?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1740&q=80"
|
||||
alt={space.name}
|
||||
className="h-full w-full object-cover grayscale duration-700 group-hover:grayscale-0"
|
||||
width={248}
|
||||
height={248}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-background/80 to-transparent" />
|
||||
</Link>
|
||||
<div className="absolute top-2 right-2">
|
||||
<div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full bg-background/50 backdrop-blur-sm hover:bg-destructive/90 cursor-pointer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("delete_search_space")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("delete_space_confirm", { name: space.name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDeleteSearchSpace(space.id)}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
>
|
||||
{tCommon("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
className="flex flex-1 flex-col p-4 cursor-pointer"
|
||||
href={`/dashboard/${space.id}/new-chat`}
|
||||
key={space.id}
|
||||
>
|
||||
<div className="flex flex-1 flex-col justify-between p-1">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-base md:text-lg">{space.name}</h3>
|
||||
{!space.is_owner && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] md:text-xs font-normal"
|
||||
>
|
||||
{t("shared")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs md:text-sm text-muted-foreground">
|
||||
{space.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("created")} {formatDate(space.created_at)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{space.is_owner ? (
|
||||
<UserCheck className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
)}
|
||||
<span>{space.member_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Tilt>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{searchSpaces.length === 0 && (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className="col-span-full flex flex-col items-center justify-center p-12 text-center"
|
||||
>
|
||||
<div className="rounded-full bg-muted/50 p-4 mb-4">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-base md:text-lg font-medium mb-2">{t("no_spaces_found")}</h3>
|
||||
<p className="text-xs md:text-sm text-muted-foreground mb-6">
|
||||
{t("create_first_space")}
|
||||
</p>
|
||||
<Link href="/dashboard/searchspaces">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("create_search_space")}
|
||||
</Button>
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{searchSpaces.length > 0 && (
|
||||
<motion.div variants={itemVariants} className="aspect-[4/3]">
|
||||
<Tilt
|
||||
rotationFactor={6}
|
||||
isRevese
|
||||
springOptions={{
|
||||
stiffness: 26.7,
|
||||
damping: 4.1,
|
||||
mass: 0.2,
|
||||
}}
|
||||
className="group relative rounded-lg h-full"
|
||||
>
|
||||
<Link href="/dashboard/searchspaces" className="flex h-full">
|
||||
<div className="flex flex-col items-center justify-center h-full w-full rounded-xl border border-dashed bg-muted/10 hover:border-primary/50 transition-colors">
|
||||
<Plus className="h-8 w-8 md:h-10 md:w-10 mb-2 md:mb-3 text-muted-foreground" />
|
||||
<span className="text-xs md:text-sm font-medium">
|
||||
{t("add_new_search_space")}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</Tilt>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h1 className="text-2xl font-bold">{t("welcome_title")}</h1>
|
||||
<p className="max-w-md text-muted-foreground">{t("welcome_description")}</p>
|
||||
</div>
|
||||
|
||||
<Button size="lg" onClick={onCreateClick} className="gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
{t("create_first_button")}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default DashboardPage;
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
|
||||
const { data: searchSpaces = [], isLoading, error } = useAtomValue(searchSpacesAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) return;
|
||||
|
||||
if (searchSpaces.length > 0) {
|
||||
router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`);
|
||||
}
|
||||
}, [isLoading, searchSpaces, router]);
|
||||
|
||||
if (isLoading) return <LoadingScreen />;
|
||||
if (error) return <ErrorScreen message={error?.message || "Failed to load search spaces"} />;
|
||||
|
||||
if (searchSpaces.length > 0) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EmptyState onCreateClick={() => setShowCreateDialog(true)} />
|
||||
<CreateSearchSpaceDialog open={showCreateDialog} onOpenChange={setShowCreateDialog} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { SearchSpaceForm } from "@/components/search-space-form";
|
||||
import { trackSearchSpaceCreated } from "@/lib/posthog/events";
|
||||
|
||||
export default function SearchSpacesPage() {
|
||||
const router = useRouter();
|
||||
const { mutateAsync: createSearchSpace } = useAtomValue(createSearchSpaceMutationAtom);
|
||||
|
||||
const handleCreateSearchSpace = async (data: { name: string; description?: string }) => {
|
||||
const result = await createSearchSpace({
|
||||
name: data.name,
|
||||
description: data.description || "",
|
||||
});
|
||||
|
||||
// Track search space creation
|
||||
trackSearchSpaceCreated(result.id, data.name);
|
||||
|
||||
// Redirect to the newly created search space's onboarding
|
||||
router.push(`/dashboard/${result.id}/onboard`);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="mx-auto max-w-5xl px-4 py-6 lg:py-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<SearchSpaceForm onSubmit={handleCreateSearchSpace} />
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
317
surfsense_web/app/dashboard/user/settings/page.tsx
Normal file
317
surfsense_web/app/dashboard/user/settings/page.tsx
Normal file
|
|
@ -0,0 +1,317 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Check,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Key,
|
||||
type LucideIcon,
|
||||
Menu,
|
||||
Shield,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useApiKey } from "@/hooks/use-api-key";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SettingsNavItem {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
}
|
||||
|
||||
function UserSettingsSidebar({
|
||||
activeSection,
|
||||
onSectionChange,
|
||||
onBackToApp,
|
||||
isOpen,
|
||||
onClose,
|
||||
navItems,
|
||||
}: {
|
||||
activeSection: string;
|
||||
onSectionChange: (section: string) => void;
|
||||
onBackToApp: () => void;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
navItems: SettingsNavItem[];
|
||||
}) {
|
||||
const t = useTranslations("userSettings");
|
||||
|
||||
const handleNavClick = (sectionId: string) => {
|
||||
onSectionChange(sectionId);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm md:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed left-0 top-0 z-50 md:relative md:z-auto",
|
||||
"flex h-full w-72 shrink-0 flex-col bg-background md:bg-muted/30",
|
||||
"md:border-r",
|
||||
"transition-transform duration-300 ease-out",
|
||||
"md:translate-x-0",
|
||||
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onBackToApp}
|
||||
className="group h-11 flex-1 justify-start gap-3 px-3 hover:bg-muted"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 transition-colors group-hover:bg-primary/20">
|
||||
<ArrowLeft className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<span className="font-medium">{t("back_to_app")}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-9 w-9 md:hidden">
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-2">
|
||||
{navItems.map((item, index) => {
|
||||
const isActive = activeSection === item.id;
|
||||
const Icon = item.icon;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={item.id}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.1 + index * 0.05, duration: 0.3 }}
|
||||
onClick={() => handleNavClick(item.id)}
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
className={cn(
|
||||
"relative flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-all duration-200",
|
||||
isActive ? "border border-border bg-muted shadow-sm" : "hover:bg-muted/60"
|
||||
)}
|
||||
>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
layoutId="userSettingsActiveIndicator"
|
||||
className="absolute left-0 top-1/2 h-8 w-1 -translate-y-1/2 rounded-r-full bg-primary"
|
||||
initial={false}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 35,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-9 w-9 items-center justify-center rounded-lg transition-colors",
|
||||
isActive ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={cn(
|
||||
"truncate text-sm font-medium transition-colors",
|
||||
isActive ? "text-foreground" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground/70">{item.description}</p>
|
||||
</div>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 transition-all",
|
||||
isActive
|
||||
? "translate-x-0 text-primary opacity-100"
|
||||
: "-translate-x-1 text-muted-foreground/40 opacity-0"
|
||||
)}
|
||||
/>
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4">
|
||||
<p className="text-center text-xs text-muted-foreground">{t("footer")}</p>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyContent({ onMenuClick }: { onMenuClick: () => void }) {
|
||||
const t = useTranslations("userSettings");
|
||||
const { apiKey, isLoading, copied, copyToClipboard } = useApiKey();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.4 }}
|
||||
className="h-full min-w-0 flex-1 overflow-hidden bg-background"
|
||||
>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mx-auto max-w-4xl p-4 md:p-6 lg:p-10">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="api-key-header"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="mb-6 md:mb-8"
|
||||
>
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onMenuClick}
|
||||
className="h-10 w-10 shrink-0 md:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.1, duration: 0.3 }}
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-primary/10 bg-gradient-to-br from-primary/20 to-primary/5 shadow-sm md:h-14 md:w-14 md:rounded-2xl"
|
||||
>
|
||||
<Key className="h-5 w-5 text-primary md:h-7 md:w-7" />
|
||||
</motion.div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-lg font-bold tracking-tight md:text-2xl">
|
||||
{t("api_key_title")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">{t("api_key_description")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key="api-key-content"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
transition={{ duration: 0.35, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<Alert>
|
||||
<Shield className="h-4 w-4" />
|
||||
<AlertTitle>{t("api_key_warning_title")}</AlertTitle>
|
||||
<AlertDescription>{t("api_key_warning_description")}</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<h3 className="mb-4 font-medium">{t("your_api_key")}</h3>
|
||||
{isLoading ? (
|
||||
<div className="h-12 w-full animate-pulse rounded-md bg-muted" />
|
||||
) : apiKey ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 overflow-x-auto rounded-md bg-muted p-3 font-mono text-sm">
|
||||
{apiKey}
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyToClipboard}
|
||||
className="shrink-0"
|
||||
>
|
||||
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{copied ? t("copied") : t("copy")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground">{t("no_api_key")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-card p-6">
|
||||
<h3 className="mb-2 font-medium">{t("usage_title")}</h3>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{t("usage_description")}</p>
|
||||
<pre className="overflow-x-auto rounded-md bg-muted p-3 text-sm">
|
||||
<code>Authorization: Bearer {apiKey || "YOUR_API_KEY"}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserSettingsPage() {
|
||||
const t = useTranslations("userSettings");
|
||||
const router = useRouter();
|
||||
const [activeSection, setActiveSection] = useState("api-key");
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
|
||||
const navItems: SettingsNavItem[] = [
|
||||
{
|
||||
id: "api-key",
|
||||
label: t("api_key_nav_label"),
|
||||
description: t("api_key_nav_description"),
|
||||
icon: Key,
|
||||
},
|
||||
];
|
||||
|
||||
const handleBackToApp = useCallback(() => {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="fixed inset-0 z-50 flex bg-muted/40"
|
||||
>
|
||||
<div className="flex h-full w-full p-0 md:p-2">
|
||||
<div className="flex h-full w-full overflow-hidden bg-background md:rounded-xl md:border md:shadow-sm">
|
||||
<UserSettingsSidebar
|
||||
activeSection={activeSection}
|
||||
onSectionChange={setActiveSection}
|
||||
onBackToApp={handleBackToApp}
|
||||
isOpen={isSidebarOpen}
|
||||
onClose={() => setIsSidebarOpen(false)}
|
||||
navItems={navItems}
|
||||
/>
|
||||
{activeSection === "api-key" && (
|
||||
<ApiKeyContent onMenuClick={() => setIsSidebarOpen(true)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { userApiService } from "@/lib/apis/user-api.service";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
export const currentUserAtom = atomWithQuery(() => {
|
||||
return {
|
||||
queryKey: cacheKeys.user.current(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
// Only fetch user data when a bearer token is present
|
||||
enabled: !!getBearerToken(),
|
||||
queryFn: async () => {
|
||||
return userApiService.getMe();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { Button } from "@/components/ui/button";
|
|||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import type { LogActiveTask } from "@/contracts/types/log.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConnectorStatus } from "../hooks/use-connector-status";
|
||||
import { ConnectorStatusBadge } from "./connector-status-badge";
|
||||
|
||||
interface ConnectorCardProps {
|
||||
id: string;
|
||||
|
|
@ -104,6 +106,15 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
onConnect,
|
||||
onManage,
|
||||
}) => {
|
||||
// Get connector status
|
||||
const { getConnectorStatus, isConnectorEnabled, getConnectorStatusMessage, shouldShowWarnings } =
|
||||
useConnectorStatus();
|
||||
|
||||
const status = getConnectorStatus(connectorType);
|
||||
const isEnabled = isConnectorEnabled(connectorType);
|
||||
const statusMessage = getConnectorStatusMessage(connectorType);
|
||||
const showWarnings = shouldShowWarnings();
|
||||
|
||||
// Extract count from active task message during indexing
|
||||
const indexingCount = extractIndexedCount(activeTask?.message);
|
||||
|
||||
|
|
@ -139,9 +150,23 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
return description;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
|
||||
const cardContent = (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex items-center gap-4 p-4 rounded-xl text-left transition-all duration-200 w-full border",
|
||||
status.status === "warning"
|
||||
? "border-yellow-500/30 bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
|
||||
: "border-border bg-slate-400/5 dark:bg-white/5 hover:bg-slate-400/10 dark:hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-12 w-12 items-center justify-center rounded-lg transition-colors shrink-0 border",
|
||||
status.status === "warning"
|
||||
? "bg-yellow-500/10 border-yellow-500/20 bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
|
||||
: "bg-slate-400/5 dark:bg-white/5 border-slate-400/5 dark:border-white/5"
|
||||
)}
|
||||
>
|
||||
{connectorType ? (
|
||||
getConnectorIcon(connectorType, "size-6")
|
||||
) : id === "youtube-crawler" ? (
|
||||
|
|
@ -151,8 +176,15 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[14px] font-semibold leading-tight truncate">{title}</span>
|
||||
{showWarnings && status.status !== "active" && (
|
||||
<ConnectorStatusBadge
|
||||
status={status.status}
|
||||
statusMessage={statusMessage}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground mt-1">{getStatusContent()}</div>
|
||||
{isConnected && documentCount !== undefined && (
|
||||
|
|
@ -179,10 +211,12 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
!isConnected && "shadow-xs"
|
||||
)}
|
||||
onClick={isConnected ? onManage : onConnect}
|
||||
disabled={isConnecting}
|
||||
disabled={isConnecting || !isEnabled}
|
||||
>
|
||||
{isConnecting ? (
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
) : !isEnabled ? (
|
||||
"Unavailable"
|
||||
) : isConnected ? (
|
||||
"Manage"
|
||||
) : id === "youtube-crawler" ? (
|
||||
|
|
@ -195,4 +229,6 @@ export const ConnectorCard: FC<ConnectorCardProps> = ({
|
|||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return cardContent;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, Ban, Wrench } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ConnectorStatus } from "../config/connector-status-config";
|
||||
|
||||
interface ConnectorStatusBadgeProps {
|
||||
status: ConnectorStatus;
|
||||
statusMessage?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ConnectorStatusBadge: FC<ConnectorStatusBadgeProps> = ({
|
||||
status,
|
||||
statusMessage,
|
||||
className,
|
||||
}) => {
|
||||
if (status === "active") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getBadgeConfig = () => {
|
||||
switch (status) {
|
||||
case "warning":
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
className: "text-yellow-500 dark:text-yellow-400",
|
||||
defaultTitle: "Warning",
|
||||
};
|
||||
case "disabled":
|
||||
return {
|
||||
icon: Ban,
|
||||
className: "text-red-500 dark:text-red-400",
|
||||
defaultTitle: "Disabled",
|
||||
};
|
||||
case "maintenance":
|
||||
return {
|
||||
icon: Wrench,
|
||||
className: "text-orange-500 dark:text-orange-400",
|
||||
defaultTitle: "Maintenance",
|
||||
};
|
||||
case "deprecated":
|
||||
return {
|
||||
icon: AlertTriangle,
|
||||
className: "ext-slate-500 dark:text-slate-400",
|
||||
defaultTitle: "Deprecated",
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const config = getBadgeConfig();
|
||||
if (!config) return null;
|
||||
|
||||
const Icon = config.icon;
|
||||
// Show statusMessage in tooltip for warning, deprecated, disabled, and maintenance statuses
|
||||
const shouldUseTooltip =
|
||||
(status === "warning" ||
|
||||
status === "deprecated" ||
|
||||
status === "disabled" ||
|
||||
status === "maintenance") &&
|
||||
statusMessage;
|
||||
const tooltipTitle = shouldUseTooltip ? statusMessage : config.defaultTitle;
|
||||
|
||||
// Use Tooltip component for statuses with statusMessage, native title for others
|
||||
if (shouldUseTooltip) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("inline-flex items-center justify-center shrink-0", className)}>
|
||||
<Icon className={cn("size-3.5", config.className)} />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
{statusMessage}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("inline-flex items-center justify-center shrink-0", className)}
|
||||
title={tooltipTitle}
|
||||
>
|
||||
<Icon className={cn("size-3.5", config.className)} />
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ConnectorWarningBannerProps {
|
||||
warning: string;
|
||||
statusMessage?: string | null;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ConnectorWarningBanner: FC<ConnectorWarningBannerProps> = ({
|
||||
warning,
|
||||
statusMessage,
|
||||
onDismiss,
|
||||
className,
|
||||
}) => {
|
||||
const [isDismissed, setIsDismissed] = useState(false);
|
||||
|
||||
if (isDismissed) return null;
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsDismissed(true);
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 rounded-lg border border-yellow-500/30 bg-yellow-500/10 dark:bg-yellow-500/5 mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<AlertTriangle className="size-4 text-yellow-600 dark:text-yellow-500 shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[12px] font-medium text-yellow-900 dark:text-yellow-200">{warning}</p>
|
||||
{statusMessage && (
|
||||
<p className="text-[11px] text-yellow-700 dark:text-yellow-300 mt-1">{statusMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="shrink-0 p-0.5 rounded hover:bg-yellow-500/20 transition-colors"
|
||||
aria-label="Dismiss warning"
|
||||
>
|
||||
<X className="size-3.5 text-yellow-700 dark:text-yellow-300" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"connectorStatuses": {
|
||||
"SLACK_CONNECTOR": {
|
||||
"enabled": false,
|
||||
"status": "disabled",
|
||||
"statusMessage": "Unavailable due to API changes"
|
||||
},
|
||||
"NOTION_CONNECTOR": {
|
||||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Rate limits may apply"
|
||||
},
|
||||
"TEAMS_CONNECTOR": {
|
||||
"enabled": false,
|
||||
"status": "maintenance",
|
||||
"statusMessage": "Temporarily unavailable for maintenance"
|
||||
},
|
||||
"JIRA_CONNECTOR": {
|
||||
"enabled": false,
|
||||
"status": "deprecated",
|
||||
"statusMessage": "Deprecated"
|
||||
}
|
||||
},
|
||||
"globalSettings": {
|
||||
"showWarnings": true,
|
||||
"allowManualOverride": false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"connectorStatuses": {
|
||||
"GOOGLE_DRIVE_CONNECTOR": {
|
||||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
|
||||
},
|
||||
"GOOGLE_GMAIL_CONNECTOR": {
|
||||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
|
||||
},
|
||||
"GOOGLE_CALENDAR_CONNECTOR": {
|
||||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Our Google OAuth app is not verified. You may see a 'non-verified app' warning during sign-in."
|
||||
},
|
||||
"YOUTUBE_CONNECTOR": {
|
||||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Doesn't work on cloud version due to YouTube blocks. Will be fixed soon."
|
||||
},
|
||||
"WEBCRAWLER_CONNECTOR": {
|
||||
"enabled": true,
|
||||
"status": "warning",
|
||||
"statusMessage": "Some requests may be blocked if not using Firecrawl."
|
||||
},
|
||||
"GITHUB_CONNECTOR": {
|
||||
"enabled": false,
|
||||
"status": "maintenance",
|
||||
"statusMessage": "Rework in progress."
|
||||
}
|
||||
},
|
||||
"globalSettings": {
|
||||
"showWarnings": true,
|
||||
"allowManualOverride": false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/**
|
||||
* Connector Status Configuration
|
||||
*
|
||||
* Manages connector statuses (disable/enable, status messages). Edit connector-status-config.json to configure.
|
||||
* Valid status values: "active", "warning", "disabled", "deprecated", "maintenance".
|
||||
* Unlisted connectors default to "active" and enabled. See connector-status-config.example.json for reference.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import rawConnectorStatusConfigData from "./connector-status-config.json";
|
||||
|
||||
// Zod schemas for runtime validation and type safety
|
||||
export const connectorStatusSchema = z.enum([
|
||||
"active",
|
||||
"warning",
|
||||
"disabled",
|
||||
"deprecated",
|
||||
"maintenance",
|
||||
]);
|
||||
|
||||
export const connectorStatusConfigSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
status: connectorStatusSchema,
|
||||
statusMessage: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const connectorStatusMapSchema = z.record(z.string(), connectorStatusConfigSchema);
|
||||
|
||||
export const connectorStatusConfigFileSchema = z.object({
|
||||
connectorStatuses: connectorStatusMapSchema,
|
||||
globalSettings: z.object({
|
||||
showWarnings: z.boolean(),
|
||||
allowManualOverride: z.boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
// TypeScript types inferred from Zod schemas
|
||||
export type ConnectorStatus = z.infer<typeof connectorStatusSchema>;
|
||||
export type ConnectorStatusConfig = z.infer<typeof connectorStatusConfigSchema>;
|
||||
export type ConnectorStatusMap = z.infer<typeof connectorStatusMapSchema>;
|
||||
export type ConnectorStatusConfigFile = z.infer<typeof connectorStatusConfigFileSchema>;
|
||||
|
||||
/**
|
||||
* Validated at runtime via Zod schema; invalid JSON throws at module load time.
|
||||
*/
|
||||
export const connectorStatusConfig: ConnectorStatusConfigFile =
|
||||
connectorStatusConfigFileSchema.parse(rawConnectorStatusConfigData);
|
||||
|
||||
/**
|
||||
* Get default status config for a connector (when not in config file)
|
||||
* Returns a validated default config
|
||||
*/
|
||||
export function getDefaultConnectorStatus(): ConnectorStatusConfig {
|
||||
return connectorStatusConfigSchema.parse({
|
||||
enabled: true,
|
||||
status: "active",
|
||||
statusMessage: null,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a connector status config object
|
||||
* Useful for validating config loaded from external sources
|
||||
*/
|
||||
export function validateConnectorStatusConfig(config: unknown): ConnectorStatusConfigFile {
|
||||
return connectorStatusConfigFileSchema.parse(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a single connector status config
|
||||
*/
|
||||
export function validateSingleConnectorStatus(config: unknown): ConnectorStatusConfig {
|
||||
return connectorStatusConfigSchema.parse(config);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
type ConnectorStatusConfig,
|
||||
connectorStatusConfig,
|
||||
getDefaultConnectorStatus,
|
||||
} from "../config/connector-status-config";
|
||||
|
||||
/**
|
||||
* Hook to get connector status information
|
||||
*/
|
||||
export function useConnectorStatus() {
|
||||
/**
|
||||
* Get status configuration for a specific connector type
|
||||
*/
|
||||
const getConnectorStatus = (connectorType: string | undefined): ConnectorStatusConfig => {
|
||||
if (!connectorType) {
|
||||
return getDefaultConnectorStatus();
|
||||
}
|
||||
|
||||
return connectorStatusConfig.connectorStatuses[connectorType] || getDefaultConnectorStatus();
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a connector is enabled
|
||||
*/
|
||||
const isConnectorEnabled = (connectorType: string | undefined): boolean => {
|
||||
return getConnectorStatus(connectorType).enabled;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get status message for a connector
|
||||
*/
|
||||
const getConnectorStatusMessage = (connectorType: string | undefined): string | null => {
|
||||
return getConnectorStatus(connectorType).statusMessage || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if warnings should be shown globally
|
||||
*/
|
||||
const shouldShowWarnings = (): boolean => {
|
||||
return connectorStatusConfig.globalSettings.showWarnings;
|
||||
};
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
getConnectorStatus,
|
||||
isConnectorEnabled,
|
||||
getConnectorStatusMessage,
|
||||
shouldShowWarnings,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
|||
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
|
||||
import type { LogActiveTask, LogSummary } from "@/contracts/types/log.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useConnectorStatus } from "../hooks/use-connector-status";
|
||||
import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
|
||||
|
||||
interface ConnectorAccountsListViewProps {
|
||||
|
|
@ -65,13 +66,19 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
onAddAccount,
|
||||
isConnecting = false,
|
||||
}) => {
|
||||
// Get connector status
|
||||
const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus();
|
||||
|
||||
const isEnabled = isConnectorEnabled(connectorType);
|
||||
const statusMessage = getConnectorStatusMessage(connectorType);
|
||||
|
||||
// Filter connectors to only show those of this type
|
||||
const typeConnectors = connectors.filter((c) => c.connector_type === connectorType);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-4 border-b border-border/50 bg-muted">
|
||||
<div className="px-6 sm:px-12 pt-8 sm:pt-10 pb-1 sm:pb-4 border-b border-border/50 bg-muted">
|
||||
{/* Back button */}
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -93,7 +100,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
{connectorTitle}
|
||||
</h2>
|
||||
<p className="text-xs sm:text-base text-muted-foreground mt-1">
|
||||
Manage your connector settings and sync configuration
|
||||
{statusMessage || "Manage your connector settings and sync configuration"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -101,21 +108,23 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
<button
|
||||
type="button"
|
||||
onClick={onAddAccount}
|
||||
disabled={isConnecting}
|
||||
disabled={isConnecting || !isEnabled}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg border-2 border-dashed border-border/70 text-left transition-all duration-200 shrink-0 self-center sm:self-auto sm:w-auto",
|
||||
"border-primary/50 hover:bg-primary/5",
|
||||
"flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-lg border-2 border-dashed text-left transition-all duration-200 shrink-0 self-center sm:self-auto sm:w-auto",
|
||||
!isEnabled
|
||||
? "border-border/30 opacity-50 cursor-not-allowed"
|
||||
: "border-primary/50 hover:bg-primary/5",
|
||||
isConnecting && "opacity-50 cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 shrink-0">
|
||||
<div className="flex h-5 w-5 sm:h-6 sm:w-6 items-center justify-center rounded-md bg-primary/10 shrink-0">
|
||||
{isConnecting ? (
|
||||
<Loader2 className="size-3.5 animate-spin text-primary" />
|
||||
<Loader2 className="size-3 sm:size-3.5 animate-spin text-primary" />
|
||||
) : (
|
||||
<Plus className="size-3.5 text-primary" />
|
||||
<Plus className="size-3 sm:size-3.5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-[12px] font-medium">
|
||||
<span className="text-[11px] sm:text-[12px] font-medium">
|
||||
{isConnecting ? "Connecting..." : "Add Account"}
|
||||
</span>
|
||||
</button>
|
||||
|
|
@ -123,7 +132,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 sm:px-12 py-6 sm:py-8">
|
||||
<div className="flex-1 overflow-y-auto px-6 sm:px-12 pt-0 sm:pt-6 pb-6 sm:pb-8">
|
||||
{/* Connected Accounts Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{typeConnectors.map((connector) => {
|
||||
|
|
|
|||
|
|
@ -7,13 +7,19 @@ import { SourceDetailPanel } from "@/components/new-chat/source-detail-panel";
|
|||
interface InlineCitationProps {
|
||||
chunkId: number;
|
||||
citationNumber: number;
|
||||
isDocsChunk?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline citation component for the new chat.
|
||||
* Renders a clickable numbered badge that opens the SourceDetailPanel with document chunk details.
|
||||
* Supports both regular knowledge base chunks and Surfsense documentation chunks.
|
||||
*/
|
||||
export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, citationNumber }) => {
|
||||
export const InlineCitation: FC<InlineCitationProps> = ({
|
||||
chunkId,
|
||||
citationNumber,
|
||||
isDocsChunk = false,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -21,10 +27,11 @@ export const InlineCitation: FC<InlineCitationProps> = ({ chunkId, citationNumbe
|
|||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
chunkId={chunkId}
|
||||
sourceType=""
|
||||
title="Source"
|
||||
sourceType={isDocsChunk ? "SURFSENSE_DOCS" : ""}
|
||||
title={isDocsChunk ? "Surfsense Documentation" : "Source"}
|
||||
description=""
|
||||
url=""
|
||||
isDocsChunk={isDocsChunk}
|
||||
>
|
||||
<span
|
||||
onClick={() => setIsOpen(true)}
|
||||
|
|
|
|||
|
|
@ -15,12 +15,13 @@ import { InlineCitation } from "@/components/assistant-ui/inline-citation";
|
|||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Citation pattern: [citation:CHUNK_ID]
|
||||
const CITATION_REGEX = /\[citation:(\d+)\]/g;
|
||||
// Citation pattern: [citation:CHUNK_ID] or [citation:doc-CHUNK_ID]
|
||||
const CITATION_REGEX = /\[citation:(doc-)?(\d+)\]/g;
|
||||
|
||||
// Track chunk IDs to citation numbers mapping for consistent numbering
|
||||
// This map is reset when a new message starts rendering
|
||||
let chunkIdToCitationNumber: Map<number, number> = new Map();
|
||||
// Uses string keys to differentiate between doc and regular chunks (e.g., "doc-123" vs "123")
|
||||
let chunkIdToCitationNumber: Map<string, number> = new Map();
|
||||
let nextCitationNumber = 1;
|
||||
|
||||
/**
|
||||
|
|
@ -33,16 +34,20 @@ export function resetCitationCounter() {
|
|||
|
||||
/**
|
||||
* Gets or assigns a citation number for a chunk ID
|
||||
* Uses string key to differentiate between doc and regular chunks
|
||||
*/
|
||||
function getCitationNumber(chunkId: number): number {
|
||||
if (!chunkIdToCitationNumber.has(chunkId)) {
|
||||
chunkIdToCitationNumber.set(chunkId, nextCitationNumber++);
|
||||
function getCitationNumber(chunkId: number, isDocsChunk: boolean): number {
|
||||
const key = isDocsChunk ? `doc-${chunkId}` : String(chunkId);
|
||||
const existingNumber = chunkIdToCitationNumber.get(key);
|
||||
if (existingNumber === undefined) {
|
||||
chunkIdToCitationNumber.set(key, nextCitationNumber++);
|
||||
}
|
||||
return chunkIdToCitationNumber.get(chunkId)!;
|
||||
return chunkIdToCitationNumber.get(key)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses text and replaces [citation:XXX] patterns with InlineCitation components
|
||||
* Supports both regular chunks [citation:123] and docs chunks [citation:doc-123]
|
||||
*/
|
||||
function parseTextWithCitations(text: string): ReactNode[] {
|
||||
const parts: ReactNode[] = [];
|
||||
|
|
@ -59,14 +64,16 @@ function parseTextWithCitations(text: string): ReactNode[] {
|
|||
parts.push(text.substring(lastIndex, match.index));
|
||||
}
|
||||
|
||||
// Add the citation component
|
||||
const chunkId = Number.parseInt(match[1], 10);
|
||||
const citationNumber = getCitationNumber(chunkId);
|
||||
// Check if this is a docs chunk (has "doc-" prefix)
|
||||
const isDocsChunk = match[1] === "doc-";
|
||||
const chunkId = Number.parseInt(match[2], 10);
|
||||
const citationNumber = getCitationNumber(chunkId, isDocsChunk);
|
||||
parts.push(
|
||||
<InlineCitation
|
||||
key={`citation-${chunkId}-${instanceIndex}`}
|
||||
key={`citation-${isDocsChunk ? "doc-" : ""}${chunkId}-${instanceIndex}`}
|
||||
chunkId={chunkId}
|
||||
citationNumber={citationNumber}
|
||||
isDocsChunk={isDocsChunk}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ export type {
|
|||
NavItem,
|
||||
NoteItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
SidebarSectionProps,
|
||||
User,
|
||||
Workspace,
|
||||
} from "./types/layout.types";
|
||||
export {
|
||||
AllSearchSpacesSheet,
|
||||
ChatListItem,
|
||||
CreateSearchSpaceDialog,
|
||||
Header,
|
||||
IconRail,
|
||||
LayoutShell,
|
||||
|
|
@ -21,10 +23,10 @@ export {
|
|||
NavSection,
|
||||
NoteListItem,
|
||||
PageUsageDisplay,
|
||||
SearchSpaceAvatar,
|
||||
Sidebar,
|
||||
SidebarCollapseButton,
|
||||
SidebarHeader,
|
||||
SidebarSection,
|
||||
SidebarUserProfile,
|
||||
WorkspaceAvatar,
|
||||
} from "./ui";
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { useTranslations } from "next-intl";
|
|||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
|
|
@ -25,7 +26,9 @@ import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
|
|||
import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence";
|
||||
import { resetUser, trackLogout } from "@/lib/posthog/events";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import type { ChatItem, NavItem, NoteItem, Workspace } from "../types/layout.types";
|
||||
import type { ChatItem, NavItem, NoteItem, SearchSpace } from "../types/layout.types";
|
||||
import { CreateSearchSpaceDialog } from "../ui/dialogs";
|
||||
import { AllSearchSpacesSheet } from "../ui/sheets";
|
||||
import { LayoutShell } from "../ui/shell";
|
||||
import { AllChatsSidebar } from "../ui/sidebar/AllChatsSidebar";
|
||||
import { AllNotesSidebar } from "../ui/sidebar/AllNotesSidebar";
|
||||
|
|
@ -53,7 +56,8 @@ export function LayoutDataProvider({
|
|||
|
||||
// Atoms
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
const { data: searchSpacesData } = useAtomValue(searchSpacesAtom);
|
||||
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
|
||||
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
|
||||
const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
|
||||
const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
|
||||
|
||||
|
|
@ -110,6 +114,10 @@ export function LayoutDataProvider({
|
|||
const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false);
|
||||
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
|
||||
|
||||
// Search space sheet and dialog state
|
||||
const [isAllSearchSpacesSheetOpen, setIsAllSearchSpacesSheetOpen] = useState(false);
|
||||
const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false);
|
||||
|
||||
// Delete dialogs state
|
||||
const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false);
|
||||
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
||||
|
|
@ -123,8 +131,7 @@ export function LayoutDataProvider({
|
|||
} | null>(null);
|
||||
const [isDeletingNote, setIsDeletingNote] = useState(false);
|
||||
|
||||
// Transform workspaces (API returns array directly, not { items: [...] })
|
||||
const workspaces: Workspace[] = useMemo(() => {
|
||||
const searchSpaces: SearchSpace[] = useMemo(() => {
|
||||
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
|
||||
return searchSpacesData.map((space) => ({
|
||||
id: space.id,
|
||||
|
|
@ -132,19 +139,15 @@ export function LayoutDataProvider({
|
|||
description: space.description,
|
||||
isOwner: space.is_owner,
|
||||
memberCount: space.member_count || 0,
|
||||
createdAt: space.created_at,
|
||||
}));
|
||||
}, [searchSpacesData]);
|
||||
|
||||
// Use searchSpace query result for current workspace (more reliable than finding in list)
|
||||
const activeWorkspace: Workspace | null = searchSpace
|
||||
? {
|
||||
id: searchSpace.id,
|
||||
name: searchSpace.name,
|
||||
description: searchSpace.description,
|
||||
isOwner: searchSpace.is_owner,
|
||||
memberCount: searchSpace.member_count || 0,
|
||||
}
|
||||
: null;
|
||||
// Find active search space from list (has is_owner and member_count)
|
||||
const activeSearchSpace: SearchSpace | null = useMemo(() => {
|
||||
if (!searchSpaceId || !searchSpaces.length) return null;
|
||||
return searchSpaces.find((s) => s.id === Number(searchSpaceId)) ?? null;
|
||||
}, [searchSpaceId, searchSpaces]);
|
||||
|
||||
// Transform chats
|
||||
const chats: ChatItem[] = useMemo(() => {
|
||||
|
|
@ -196,20 +199,47 @@ export function LayoutDataProvider({
|
|||
);
|
||||
|
||||
// Handlers
|
||||
const handleWorkspaceSelect = useCallback(
|
||||
const handleSearchSpaceSelect = useCallback(
|
||||
(id: number) => {
|
||||
router.push(`/dashboard/${id}/new-chat`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleAddWorkspace = useCallback(() => {
|
||||
router.push("/dashboard/searchspaces");
|
||||
const handleAddSearchSpace = useCallback(() => {
|
||||
setIsCreateSearchSpaceDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSeeAllSearchSpaces = useCallback(() => {
|
||||
setIsAllSearchSpacesSheetOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleUserSettings = useCallback(() => {
|
||||
router.push("/dashboard/user/settings");
|
||||
}, [router]);
|
||||
|
||||
const handleSeeAllWorkspaces = useCallback(() => {
|
||||
router.push("/dashboard");
|
||||
}, [router]);
|
||||
const handleSearchSpaceSettings = useCallback(
|
||||
(id: number) => {
|
||||
router.push(`/dashboard/${id}/settings`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteSearchSpace = useCallback(
|
||||
async (id: number) => {
|
||||
await deleteSearchSpace({ id });
|
||||
refetchSearchSpaces();
|
||||
if (Number(searchSpaceId) === id && searchSpaces.length > 1) {
|
||||
const remaining = searchSpaces.filter((s) => s.id !== id);
|
||||
if (remaining.length > 0) {
|
||||
router.push(`/dashboard/${remaining[0].id}/new-chat`);
|
||||
}
|
||||
} else if (searchSpaces.length === 1) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
},
|
||||
[deleteSearchSpace, refetchSearchSpaces, searchSpaceId, searchSpaces, router]
|
||||
);
|
||||
|
||||
const handleNavItemClick = useCallback(
|
||||
(item: NavItem) => {
|
||||
|
|
@ -266,7 +296,7 @@ export function LayoutDataProvider({
|
|||
router.push(`/dashboard/${searchSpaceId}/settings`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
const handleInviteMembers = useCallback(() => {
|
||||
const handleManageMembers = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/team`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
|
|
@ -347,11 +377,11 @@ export function LayoutDataProvider({
|
|||
return (
|
||||
<>
|
||||
<LayoutShell
|
||||
workspaces={workspaces}
|
||||
activeWorkspaceId={Number(searchSpaceId)}
|
||||
onWorkspaceSelect={handleWorkspaceSelect}
|
||||
onAddWorkspace={handleAddWorkspace}
|
||||
workspace={activeWorkspace}
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={Number(searchSpaceId)}
|
||||
onSearchSpaceSelect={handleSearchSpaceSelect}
|
||||
onAddSearchSpace={handleAddSearchSpace}
|
||||
searchSpace={activeSearchSpace}
|
||||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
chats={chats}
|
||||
|
|
@ -368,8 +398,9 @@ export function LayoutDataProvider({
|
|||
onViewAllNotes={handleViewAllNotes}
|
||||
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
|
||||
onSettings={handleSettings}
|
||||
onInviteMembers={handleInviteMembers}
|
||||
onSeeAllWorkspaces={handleSeeAllWorkspaces}
|
||||
onManageMembers={handleManageMembers}
|
||||
onSeeAllSearchSpaces={handleSeeAllSearchSpaces}
|
||||
onUserSettings={handleUserSettings}
|
||||
onLogout={handleLogout}
|
||||
pageUsage={pageUsage}
|
||||
breadcrumb={breadcrumb}
|
||||
|
|
@ -439,6 +470,26 @@ export function LayoutDataProvider({
|
|||
onAddNote={handleAddNote}
|
||||
/>
|
||||
|
||||
{/* All Search Spaces Sheet */}
|
||||
<AllSearchSpacesSheet
|
||||
open={isAllSearchSpacesSheetOpen}
|
||||
onOpenChange={setIsAllSearchSpacesSheetOpen}
|
||||
searchSpaces={searchSpaces}
|
||||
onSearchSpaceSelect={handleSearchSpaceSelect}
|
||||
onCreateNew={() => {
|
||||
setIsAllSearchSpacesSheetOpen(false);
|
||||
setIsCreateSearchSpaceDialogOpen(true);
|
||||
}}
|
||||
onSettings={handleSearchSpaceSettings}
|
||||
onDelete={handleDeleteSearchSpace}
|
||||
/>
|
||||
|
||||
{/* Create Search Space Dialog */}
|
||||
<CreateSearchSpaceDialog
|
||||
open={isCreateSearchSpaceDialogOpen}
|
||||
onOpenChange={setIsCreateSearchSpaceDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Delete Note Dialog */}
|
||||
<Dialog open={showDeleteNoteDialog} onOpenChange={setShowDeleteNoteDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export interface Workspace {
|
||||
export interface SearchSpace {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isOwner: boolean;
|
||||
memberCount: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
|
|
@ -42,15 +43,15 @@ export interface PageUsage {
|
|||
}
|
||||
|
||||
export interface IconRailProps {
|
||||
workspaces: Workspace[];
|
||||
activeWorkspaceId: number | null;
|
||||
onWorkspaceSelect: (id: number) => void;
|
||||
onAddWorkspace: () => void;
|
||||
searchSpaces: SearchSpace[];
|
||||
activeSearchSpaceId: number | null;
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onAddSearchSpace: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface SidebarHeaderProps {
|
||||
workspace: Workspace | null;
|
||||
searchSpace: SearchSpace | null;
|
||||
onSettings?: () => void;
|
||||
}
|
||||
|
||||
|
|
@ -94,15 +95,15 @@ export interface SidebarUserProfileProps {
|
|||
user: User;
|
||||
searchSpaceId?: string;
|
||||
onSettings?: () => void;
|
||||
onInviteMembers?: () => void;
|
||||
onSwitchWorkspace?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
onSwitchSearchSpace?: () => void;
|
||||
onToggleTheme?: () => void;
|
||||
onLogout?: () => void;
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
export interface SidebarProps {
|
||||
workspace: Workspace | null;
|
||||
searchSpace: SearchSpace | null;
|
||||
searchSpaceId?: string;
|
||||
navItems: NavItem[];
|
||||
chats: ChatItem[];
|
||||
|
|
@ -120,8 +121,8 @@ export interface SidebarProps {
|
|||
user: User;
|
||||
theme?: string;
|
||||
onSettings?: () => void;
|
||||
onInviteMembers?: () => void;
|
||||
onSwitchWorkspace?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
onSeeAllSearchSpaces?: () => void;
|
||||
onToggleTheme?: () => void;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
|
|
@ -129,10 +130,10 @@ export interface SidebarProps {
|
|||
}
|
||||
|
||||
export interface LayoutShellProps {
|
||||
workspaces: Workspace[];
|
||||
activeWorkspaceId: number | null;
|
||||
onWorkspaceSelect: (id: number) => void;
|
||||
onAddWorkspace: () => void;
|
||||
searchSpaces: SearchSpace[];
|
||||
activeSearchSpaceId: number | null;
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onAddSearchSpace: () => void;
|
||||
sidebarProps: Omit<SidebarProps, "className">;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,161 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Loader2, Plus, Search } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import { createSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { trackSearchSpaceCreated } from "@/lib/posthog/events";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
interface CreateSearchSpaceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateSearchSpaceDialog({ open, onOpenChange }: CreateSearchSpaceDialogProps) {
|
||||
const t = useTranslations("searchSpace");
|
||||
const tCommon = useTranslations("common");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const { mutateAsync: createSearchSpace } = useAtomValue(createSearchSpaceMutationAtom);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createSearchSpace({
|
||||
name: values.name,
|
||||
description: values.description || "",
|
||||
});
|
||||
|
||||
trackSearchSpaceCreated(result.id, values.name);
|
||||
|
||||
// Hard redirect to ensure fresh state
|
||||
window.location.href = `/dashboard/${result.id}/onboard`;
|
||||
} catch (error) {
|
||||
console.error("Failed to create search space:", error);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
form.reset();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Search className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle>{t("create_title")}</DialogTitle>
|
||||
<DialogDescription>{t("create_description")}</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="flex flex-col gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("name_label")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("name_placeholder")} {...field} autoFocus />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("description_label")}{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({tCommon("optional")})
|
||||
</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("description_placeholder")} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter className="flex gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("creating")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t("create_button")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1
surfsense_web/components/layout/ui/dialogs/index.ts
Normal file
1
surfsense_web/components/layout/ui/dialogs/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { CreateSearchSpaceDialog } from "./CreateSearchSpaceDialog";
|
||||
|
|
@ -25,7 +25,7 @@ export function Header({
|
|||
{/* Left side - Mobile menu trigger + Breadcrumb */}
|
||||
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||
{mobileMenuTrigger}
|
||||
{breadcrumb}
|
||||
<div className="hidden md:block">{breadcrumb}</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Actions */}
|
||||
|
|
|
|||
|
|
@ -5,34 +5,34 @@ import { Button } from "@/components/ui/button";
|
|||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Workspace } from "../../types/layout.types";
|
||||
import { WorkspaceAvatar } from "./WorkspaceAvatar";
|
||||
import type { SearchSpace } from "../../types/layout.types";
|
||||
import { SearchSpaceAvatar } from "./SearchSpaceAvatar";
|
||||
|
||||
interface IconRailProps {
|
||||
workspaces: Workspace[];
|
||||
activeWorkspaceId: number | null;
|
||||
onWorkspaceSelect: (id: number) => void;
|
||||
onAddWorkspace: () => void;
|
||||
searchSpaces: SearchSpace[];
|
||||
activeSearchSpaceId: number | null;
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onAddSearchSpace: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconRail({
|
||||
workspaces,
|
||||
activeWorkspaceId,
|
||||
onWorkspaceSelect,
|
||||
onAddWorkspace,
|
||||
searchSpaces,
|
||||
activeSearchSpaceId,
|
||||
onSearchSpaceSelect,
|
||||
onAddSearchSpace,
|
||||
className,
|
||||
}: IconRailProps) {
|
||||
return (
|
||||
<div className={cn("flex h-full w-14 flex-col items-center", className)}>
|
||||
<ScrollArea className="w-full">
|
||||
<div className="flex flex-col items-center gap-2 px-1.5 py-3">
|
||||
{workspaces.map((workspace) => (
|
||||
<WorkspaceAvatar
|
||||
key={workspace.id}
|
||||
name={workspace.name}
|
||||
isActive={workspace.id === activeWorkspaceId}
|
||||
onClick={() => onWorkspaceSelect(workspace.id)}
|
||||
{searchSpaces.map((searchSpace) => (
|
||||
<SearchSpaceAvatar
|
||||
key={searchSpace.id}
|
||||
name={searchSpace.name}
|
||||
isActive={searchSpace.id === activeSearchSpaceId}
|
||||
onClick={() => onSearchSpaceSelect(searchSpace.id)}
|
||||
size="md"
|
||||
/>
|
||||
))}
|
||||
|
|
@ -42,15 +42,15 @@ export function IconRail({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onAddWorkspace}
|
||||
onClick={onAddSearchSpace}
|
||||
className="h-10 w-10 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
|
||||
>
|
||||
<Plus className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="sr-only">Add workspace</span>
|
||||
<span className="sr-only">Add search space</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Add workspace
|
||||
Add search space
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface WorkspaceAvatarProps {
|
||||
interface SearchSpaceAvatarProps {
|
||||
name: string;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
|
|
@ -11,7 +11,7 @@ interface WorkspaceAvatarProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* Generates a consistent color based on workspace name
|
||||
* Generates a consistent color based on search space name
|
||||
*/
|
||||
function stringToColor(str: string): string {
|
||||
let hash = 0;
|
||||
|
|
@ -32,7 +32,7 @@ function stringToColor(str: string): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets initials from workspace name (max 2 chars)
|
||||
* Gets initials from search space name (max 2 chars)
|
||||
*/
|
||||
function getInitials(name: string): string {
|
||||
const words = name.trim().split(/\s+/);
|
||||
|
|
@ -42,7 +42,12 @@ function getInitials(name: string): string {
|
|||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function WorkspaceAvatar({ name, isActive, onClick, size = "md" }: WorkspaceAvatarProps) {
|
||||
export function SearchSpaceAvatar({
|
||||
name,
|
||||
isActive,
|
||||
onClick,
|
||||
size = "md",
|
||||
}: SearchSpaceAvatarProps) {
|
||||
const bgColor = stringToColor(name);
|
||||
const initials = getInitials(name);
|
||||
const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm";
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export { IconRail } from "./IconRail";
|
||||
export { NavIcon } from "./NavIcon";
|
||||
export { WorkspaceAvatar } from "./WorkspaceAvatar";
|
||||
export { SearchSpaceAvatar } from "./SearchSpaceAvatar";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export { CreateSearchSpaceDialog } from "./dialogs";
|
||||
export { Header } from "./header";
|
||||
export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail";
|
||||
export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail";
|
||||
export { AllSearchSpacesSheet } from "./sheets";
|
||||
export { LayoutShell } from "./shell";
|
||||
export {
|
||||
ChatListItem,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,241 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Calendar,
|
||||
MoreHorizontal,
|
||||
Search,
|
||||
Settings,
|
||||
Share2,
|
||||
Trash2,
|
||||
UserCheck,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import type { SearchSpace } from "../../types/layout.types";
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
return new Date(dateString).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
interface AllSearchSpacesSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
searchSpaces: SearchSpace[];
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onCreateNew?: () => void;
|
||||
onSettings?: (id: number) => void;
|
||||
onDelete?: (id: number) => void;
|
||||
}
|
||||
|
||||
export function AllSearchSpacesSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
searchSpaces,
|
||||
onSearchSpaceSelect,
|
||||
onCreateNew,
|
||||
onSettings,
|
||||
onDelete,
|
||||
}: AllSearchSpacesSheetProps) {
|
||||
const t = useTranslations("searchSpace");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
const [spaceToDelete, setSpaceToDelete] = useState<SearchSpace | null>(null);
|
||||
|
||||
const handleSelect = (id: number) => {
|
||||
onSearchSpaceSelect(id);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleSettings = (e: React.MouseEvent, space: SearchSpace) => {
|
||||
e.stopPropagation();
|
||||
onOpenChange(false);
|
||||
onSettings?.(space.id);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (e: React.MouseEvent, space: SearchSpace) => {
|
||||
e.stopPropagation();
|
||||
setSpaceToDelete(space);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (spaceToDelete) {
|
||||
onDelete?.(spaceToDelete.id);
|
||||
setSpaceToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Search className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SheetTitle>{t("all_search_spaces")}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{t("search_spaces_count", { count: searchSpaces.length })}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 pb-4">
|
||||
{searchSpaces.length === 0 ? (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 py-12 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<Search className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-medium">{t("no_search_spaces")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("create_first_search_space")}</p>
|
||||
</div>
|
||||
{onCreateNew && (
|
||||
<Button onClick={onCreateNew} className="mt-2">
|
||||
{t("create_button")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
searchSpaces.map((space) => (
|
||||
<button
|
||||
key={space.id}
|
||||
type="button"
|
||||
onClick={() => handleSelect(space.id)}
|
||||
className="flex w-full flex-col gap-2 rounded-lg border p-4 text-left transition-colors hover:bg-accent hover:border-accent-foreground/20"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<span className="font-medium leading-tight">{space.name}</span>
|
||||
{space.description && (
|
||||
<span className="text-sm text-muted-foreground line-clamp-2">
|
||||
{space.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{space.memberCount > 1 && (
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
<Share2 className="mr-1 h-3 w-3" />
|
||||
{tCommon("shared")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{space.isOwner && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => handleSettings(e, space)}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{tCommon("settings")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleDeleteClick(e, space)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{tCommon("delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
{space.isOwner ? (
|
||||
<UserCheck className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{t("members_count", { count: space.memberCount })}
|
||||
</span>
|
||||
{space.createdAt && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
{formatDate(space.createdAt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{searchSpaces.length > 0 && onCreateNew && (
|
||||
<div className="border-t p-4">
|
||||
<Button onClick={onCreateNew} variant="outline" className="w-full">
|
||||
{t("create_new_search_space")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog open={!!spaceToDelete} onOpenChange={(open) => !open && setSpaceToDelete(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("delete_title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("delete_confirm", { name: spaceToDelete?.name ?? "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{tCommon("delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
surfsense_web/components/layout/ui/sheets/index.ts
Normal file
1
surfsense_web/components/layout/ui/sheets/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { AllSearchSpacesSheet } from "./AllSearchSpacesSheet";
|
||||
|
|
@ -10,19 +10,19 @@ import type {
|
|||
NavItem,
|
||||
NoteItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
User,
|
||||
Workspace,
|
||||
} from "../../types/layout.types";
|
||||
import { Header } from "../header";
|
||||
import { IconRail } from "../icon-rail";
|
||||
import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
|
||||
|
||||
interface LayoutShellProps {
|
||||
workspaces: Workspace[];
|
||||
activeWorkspaceId: number | null;
|
||||
onWorkspaceSelect: (id: number) => void;
|
||||
onAddWorkspace: () => void;
|
||||
workspace: Workspace | null;
|
||||
searchSpaces: SearchSpace[];
|
||||
activeSearchSpaceId: number | null;
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onAddSearchSpace: () => void;
|
||||
searchSpace: SearchSpace | null;
|
||||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
|
|
@ -39,8 +39,9 @@ interface LayoutShellProps {
|
|||
onViewAllNotes?: () => void;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onInviteMembers?: () => void;
|
||||
onSeeAllWorkspaces?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
onSeeAllSearchSpaces?: () => void;
|
||||
onUserSettings?: () => void;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
breadcrumb?: React.ReactNode;
|
||||
|
|
@ -54,11 +55,11 @@ interface LayoutShellProps {
|
|||
}
|
||||
|
||||
export function LayoutShell({
|
||||
workspaces,
|
||||
activeWorkspaceId,
|
||||
onWorkspaceSelect,
|
||||
onAddWorkspace,
|
||||
workspace,
|
||||
searchSpaces,
|
||||
activeSearchSpaceId,
|
||||
onSearchSpaceSelect,
|
||||
onAddSearchSpace,
|
||||
searchSpace,
|
||||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
|
|
@ -75,8 +76,9 @@ export function LayoutShell({
|
|||
onViewAllNotes,
|
||||
user,
|
||||
onSettings,
|
||||
onInviteMembers,
|
||||
onSeeAllWorkspaces,
|
||||
onManageMembers,
|
||||
onSeeAllSearchSpaces,
|
||||
onUserSettings,
|
||||
onLogout,
|
||||
pageUsage,
|
||||
breadcrumb,
|
||||
|
|
@ -108,11 +110,11 @@ export function LayoutShell({
|
|||
<MobileSidebar
|
||||
isOpen={mobileMenuOpen}
|
||||
onOpenChange={setMobileMenuOpen}
|
||||
workspaces={workspaces}
|
||||
activeWorkspaceId={activeWorkspaceId}
|
||||
onWorkspaceSelect={onWorkspaceSelect}
|
||||
onAddWorkspace={onAddWorkspace}
|
||||
workspace={workspace}
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={onSearchSpaceSelect}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
searchSpace={searchSpace}
|
||||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
chats={chats}
|
||||
|
|
@ -129,8 +131,9 @@ export function LayoutShell({
|
|||
onViewAllNotes={onViewAllNotes}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onInviteMembers={onInviteMembers}
|
||||
onSeeAllWorkspaces={onSeeAllWorkspaces}
|
||||
onManageMembers={onManageMembers}
|
||||
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
|
||||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
/>
|
||||
|
|
@ -149,16 +152,16 @@ export function LayoutShell({
|
|||
<div className={cn("flex h-screen w-full gap-2 p-2 overflow-hidden bg-muted/40", className)}>
|
||||
<div className="hidden md:flex overflow-hidden">
|
||||
<IconRail
|
||||
workspaces={workspaces}
|
||||
activeWorkspaceId={activeWorkspaceId}
|
||||
onWorkspaceSelect={onWorkspaceSelect}
|
||||
onAddWorkspace={onAddWorkspace}
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={onSearchSpaceSelect}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 rounded-xl border bg-background overflow-hidden">
|
||||
<Sidebar
|
||||
workspace={workspace}
|
||||
searchSpace={searchSpace}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={toggleCollapsed}
|
||||
navItems={navItems}
|
||||
|
|
@ -177,8 +180,9 @@ export function LayoutShell({
|
|||
onViewAllNotes={onViewAllNotes}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onInviteMembers={onInviteMembers}
|
||||
onSeeAllWorkspaces={onSeeAllWorkspaces}
|
||||
onManageMembers={onManageMembers}
|
||||
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
|
||||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
className="hidden md:flex border-r shrink-0"
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import type {
|
|||
NavItem,
|
||||
NoteItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
User,
|
||||
Workspace,
|
||||
} from "../../types/layout.types";
|
||||
import { IconRail } from "../icon-rail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
|
@ -18,11 +18,11 @@ import { Sidebar } from "./Sidebar";
|
|||
interface MobileSidebarProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaces: Workspace[];
|
||||
activeWorkspaceId: number | null;
|
||||
onWorkspaceSelect: (id: number) => void;
|
||||
onAddWorkspace: () => void;
|
||||
workspace: Workspace | null;
|
||||
searchSpaces: SearchSpace[];
|
||||
activeSearchSpaceId: number | null;
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onAddSearchSpace: () => void;
|
||||
searchSpace: SearchSpace | null;
|
||||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
|
|
@ -39,8 +39,9 @@ interface MobileSidebarProps {
|
|||
onViewAllNotes?: () => void;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onInviteMembers?: () => void;
|
||||
onSeeAllWorkspaces?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
onSeeAllSearchSpaces?: () => void;
|
||||
onUserSettings?: () => void;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
}
|
||||
|
|
@ -57,11 +58,11 @@ export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
|
|||
export function MobileSidebar({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
workspaces,
|
||||
activeWorkspaceId,
|
||||
onWorkspaceSelect,
|
||||
onAddWorkspace,
|
||||
workspace,
|
||||
searchSpaces,
|
||||
activeSearchSpaceId,
|
||||
onSearchSpaceSelect,
|
||||
onAddSearchSpace,
|
||||
searchSpace,
|
||||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
|
|
@ -78,13 +79,14 @@ export function MobileSidebar({
|
|||
onViewAllNotes,
|
||||
user,
|
||||
onSettings,
|
||||
onInviteMembers,
|
||||
onSeeAllWorkspaces,
|
||||
onManageMembers,
|
||||
onSeeAllSearchSpaces,
|
||||
onUserSettings,
|
||||
onLogout,
|
||||
pageUsage,
|
||||
}: MobileSidebarProps) {
|
||||
const handleWorkspaceSelect = (id: number) => {
|
||||
onWorkspaceSelect(id);
|
||||
const handleSearchSpaceSelect = (id: number) => {
|
||||
onSearchSpaceSelect(id);
|
||||
};
|
||||
|
||||
const handleNavItemClick = (item: NavItem) => {
|
||||
|
|
@ -110,17 +112,17 @@ export function MobileSidebar({
|
|||
<div className="shrink-0 border-r bg-muted/40">
|
||||
<ScrollArea className="h-full">
|
||||
<IconRail
|
||||
workspaces={workspaces}
|
||||
activeWorkspaceId={activeWorkspaceId}
|
||||
onWorkspaceSelect={handleWorkspaceSelect}
|
||||
onAddWorkspace={onAddWorkspace}
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={handleSearchSpaceSelect}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Sidebar
|
||||
workspace={workspace}
|
||||
searchSpace={searchSpace}
|
||||
isCollapsed={false}
|
||||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
|
|
@ -141,8 +143,9 @@ export function MobileSidebar({
|
|||
onViewAllNotes={onViewAllNotes}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onInviteMembers={onInviteMembers}
|
||||
onSeeAllWorkspaces={onSeeAllWorkspaces}
|
||||
onManageMembers={onManageMembers}
|
||||
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
|
||||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
className="w-full border-none"
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import type {
|
|||
NavItem,
|
||||
NoteItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
User,
|
||||
Workspace,
|
||||
} from "../../types/layout.types";
|
||||
import { ChatListItem } from "./ChatListItem";
|
||||
import { NavSection } from "./NavSection";
|
||||
|
|
@ -24,7 +24,7 @@ import { SidebarSection } from "./SidebarSection";
|
|||
import { SidebarUserProfile } from "./SidebarUserProfile";
|
||||
|
||||
interface SidebarProps {
|
||||
workspace: Workspace | null;
|
||||
searchSpace: SearchSpace | null;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
navItems: NavItem[];
|
||||
|
|
@ -43,15 +43,16 @@ interface SidebarProps {
|
|||
onViewAllNotes?: () => void;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onInviteMembers?: () => void;
|
||||
onSeeAllWorkspaces?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
onSeeAllSearchSpaces?: () => void;
|
||||
onUserSettings?: () => void;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
workspace,
|
||||
searchSpace,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse,
|
||||
navItems,
|
||||
|
|
@ -70,8 +71,9 @@ export function Sidebar({
|
|||
onViewAllNotes,
|
||||
user,
|
||||
onSettings,
|
||||
onInviteMembers,
|
||||
onSeeAllWorkspaces,
|
||||
onManageMembers,
|
||||
onSeeAllSearchSpaces,
|
||||
onUserSettings,
|
||||
onLogout,
|
||||
pageUsage,
|
||||
className,
|
||||
|
|
@ -86,7 +88,7 @@ export function Sidebar({
|
|||
className
|
||||
)}
|
||||
>
|
||||
{/* Header - workspace name or collapse button when collapsed */}
|
||||
{/* Header - search space name or collapse button when collapsed */}
|
||||
{isCollapsed ? (
|
||||
<div className="flex h-14 shrink-0 items-center justify-center border-b">
|
||||
<SidebarCollapseButton
|
||||
|
|
@ -97,11 +99,11 @@ export function Sidebar({
|
|||
) : (
|
||||
<div className="flex h-14 shrink-0 items-center justify-between px-1 border-b">
|
||||
<SidebarHeader
|
||||
workspace={workspace}
|
||||
searchSpace={searchSpace}
|
||||
isCollapsed={isCollapsed}
|
||||
onSettings={onSettings}
|
||||
onInviteMembers={onInviteMembers}
|
||||
onSeeAllWorkspaces={onSeeAllWorkspaces}
|
||||
onManageMembers={onManageMembers}
|
||||
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
|
||||
/>
|
||||
<div className="">
|
||||
<SidebarCollapseButton
|
||||
|
|
@ -287,7 +289,12 @@ export function Sidebar({
|
|||
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
|
||||
)}
|
||||
|
||||
<SidebarUserProfile user={user} onLogout={onLogout} isCollapsed={isCollapsed} />
|
||||
<SidebarUserProfile
|
||||
user={user}
|
||||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronsUpDown, LayoutGrid, Settings, UserPlus } from "lucide-react";
|
||||
import { ChevronsUpDown, LayoutGrid, Settings, Users } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -11,23 +11,23 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Workspace } from "../../types/layout.types";
|
||||
import type { SearchSpace } from "../../types/layout.types";
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
workspace: Workspace | null;
|
||||
searchSpace: SearchSpace | null;
|
||||
isCollapsed?: boolean;
|
||||
onSettings?: () => void;
|
||||
onInviteMembers?: () => void;
|
||||
onSeeAllWorkspaces?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
onSeeAllSearchSpaces?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SidebarHeader({
|
||||
workspace,
|
||||
searchSpace,
|
||||
isCollapsed,
|
||||
onSettings,
|
||||
onInviteMembers,
|
||||
onSeeAllWorkspaces,
|
||||
onManageMembers,
|
||||
onSeeAllSearchSpaces,
|
||||
className,
|
||||
}: SidebarHeaderProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
|
@ -43,24 +43,26 @@ export function SidebarHeader({
|
|||
isCollapsed ? "w-10" : "w-50"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-base">{workspace?.name ?? t("select_workspace")}</span>
|
||||
<span className="truncate text-base">
|
||||
{searchSpace?.name ?? t("select_search_space")}
|
||||
</span>
|
||||
<ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuItem onClick={onInviteMembers}>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("invite_members")}
|
||||
<DropdownMenuItem onClick={onManageMembers}>
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
{t("manage_members")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onSettings}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t("workspace_settings")}
|
||||
{t("search_space_settings")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onSeeAllWorkspaces}>
|
||||
<DropdownMenuItem onClick={onSeeAllSearchSpaces}>
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
{t("see_all_workspaces")}
|
||||
{t("see_all_search_spaces")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronUp, LogOut } from "lucide-react";
|
||||
import { ChevronUp, LogOut, Settings } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -16,6 +16,7 @@ import type { User } from "../../types/layout.types";
|
|||
|
||||
interface SidebarUserProfileProps {
|
||||
user: User;
|
||||
onUserSettings?: () => void;
|
||||
onLogout?: () => void;
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
|
@ -62,6 +63,7 @@ function getInitials(email: string): string {
|
|||
|
||||
export function SidebarUserProfile({
|
||||
user,
|
||||
onUserSettings,
|
||||
onLogout,
|
||||
isCollapsed = false,
|
||||
}: SidebarUserProfileProps) {
|
||||
|
|
@ -117,6 +119,13 @@ export function SidebarUserProfile({
|
|||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onUserSettings}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t("user_settings")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t("logout")}
|
||||
|
|
@ -177,6 +186,13 @@ export function SidebarUserProfile({
|
|||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onUserSettings}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
{t("user_settings")}
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem onClick={onLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
{t("logout")}
|
||||
|
|
|
|||
|
|
@ -21,10 +21,16 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type {
|
||||
GetDocumentByChunkResponse,
|
||||
GetSurfsenseDocsByChunkResponse,
|
||||
} from "@/contracts/types/document.types";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type DocumentData = GetDocumentByChunkResponse | GetSurfsenseDocsByChunkResponse;
|
||||
|
||||
interface SourceDetailPanelProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
|
@ -34,6 +40,7 @@ interface SourceDetailPanelProps {
|
|||
description?: string;
|
||||
url?: string;
|
||||
children?: ReactNode;
|
||||
isDocsChunk?: boolean;
|
||||
}
|
||||
|
||||
const formatDocumentType = (type: string) => {
|
||||
|
|
@ -114,6 +121,7 @@ export function SourceDetailPanel({
|
|||
description,
|
||||
url,
|
||||
children,
|
||||
isDocsChunk = false,
|
||||
}: SourceDetailPanelProps) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||
const hasScrolledRef = useRef(false); // Use ref to avoid stale closures
|
||||
|
|
@ -131,9 +139,16 @@ export function SourceDetailPanel({
|
|||
data: documentData,
|
||||
isLoading: isDocumentByChunkFetching,
|
||||
error: documentByChunkFetchingError,
|
||||
} = useQuery({
|
||||
queryKey: cacheKeys.documents.byChunk(chunkId.toString()),
|
||||
queryFn: () => documentsApiService.getDocumentByChunk({ chunk_id: chunkId }),
|
||||
} = useQuery<DocumentData>({
|
||||
queryKey: isDocsChunk
|
||||
? cacheKeys.documents.byChunk(`doc-${chunkId}`)
|
||||
: cacheKeys.documents.byChunk(chunkId.toString()),
|
||||
queryFn: async () => {
|
||||
if (isDocsChunk) {
|
||||
return documentsApiService.getSurfsenseDocByChunk(chunkId);
|
||||
}
|
||||
return documentsApiService.getDocumentByChunk({ chunk_id: chunkId });
|
||||
},
|
||||
enabled: !!chunkId && open,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
|
@ -325,7 +340,7 @@ export function SourceDetailPanel({
|
|||
{documentData?.title || title || "Source Document"}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{documentData
|
||||
{documentData && "document_type" in documentData
|
||||
? formatDocumentType(documentData.document_type)
|
||||
: sourceType && formatDocumentType(sourceType)}
|
||||
{documentData?.chunks && (
|
||||
|
|
@ -491,7 +506,8 @@ export function SourceDetailPanel({
|
|||
<ScrollArea className="flex-1" ref={scrollAreaRef}>
|
||||
<div className="p-6 lg:p-8 max-w-4xl mx-auto space-y-6">
|
||||
{/* Document Metadata */}
|
||||
{documentData.document_metadata &&
|
||||
{"document_metadata" in documentData &&
|
||||
documentData.document_metadata &&
|
||||
Object.keys(documentData.document_metadata).length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,26 @@ export const documentWithChunks = document.extend({
|
|||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Surfsense documentation schemas
|
||||
* Follows the same pattern as document/documentWithChunks
|
||||
*/
|
||||
export const surfsenseDocsChunk = z.object({
|
||||
id: z.number(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const surfsenseDocsDocument = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
source: z.string(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const surfsenseDocsDocumentWithChunks = surfsenseDocsDocument.extend({
|
||||
chunks: z.array(surfsenseDocsChunk),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get documents
|
||||
*/
|
||||
|
|
@ -154,6 +174,15 @@ export const getDocumentByChunkRequest = z.object({
|
|||
|
||||
export const getDocumentByChunkResponse = documentWithChunks;
|
||||
|
||||
/**
|
||||
* Get Surfsense docs by chunk
|
||||
*/
|
||||
export const getSurfsenseDocsByChunkRequest = z.object({
|
||||
chunk_id: z.number(),
|
||||
});
|
||||
|
||||
export const getSurfsenseDocsByChunkResponse = surfsenseDocsDocumentWithChunks;
|
||||
|
||||
/**
|
||||
* Update document
|
||||
*/
|
||||
|
|
@ -193,3 +222,8 @@ export type UpdateDocumentResponse = z.infer<typeof updateDocumentResponse>;
|
|||
export type DeleteDocumentRequest = z.infer<typeof deleteDocumentRequest>;
|
||||
export type DeleteDocumentResponse = z.infer<typeof deleteDocumentResponse>;
|
||||
export type DocumentTypeEnum = z.infer<typeof documentTypeEnum>;
|
||||
export type SurfsenseDocsChunk = z.infer<typeof surfsenseDocsChunk>;
|
||||
export type SurfsenseDocsDocument = z.infer<typeof surfsenseDocsDocument>;
|
||||
export type SurfsenseDocsDocumentWithChunks = z.infer<typeof surfsenseDocsDocumentWithChunks>;
|
||||
export type GetSurfsenseDocsByChunkRequest = z.infer<typeof getSurfsenseDocsByChunkRequest>;
|
||||
export type GetSurfsenseDocsByChunkResponse = z.infer<typeof getSurfsenseDocsByChunkResponse>;
|
||||
|
|
|
|||
|
|
@ -129,20 +129,24 @@ class BaseApiService {
|
|||
throw new AppError("Failed to parse response", response.status, response.statusText);
|
||||
}
|
||||
|
||||
// Handle 401 first before other error handling - ensures token is cleared and user redirected
|
||||
if (response.status === 401) {
|
||||
handleUnauthorized();
|
||||
throw new AuthenticationError(
|
||||
typeof data === "object" && "detail" in data
|
||||
? data.detail
|
||||
: "You are not authenticated. Please login again.",
|
||||
response.status,
|
||||
response.statusText
|
||||
);
|
||||
}
|
||||
|
||||
// For fastapi errors response
|
||||
if (typeof data === "object" && "detail" in data) {
|
||||
throw new AppError(data.detail, response.status, response.statusText);
|
||||
}
|
||||
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
// Use centralized auth handler for 401 responses
|
||||
handleUnauthorized();
|
||||
throw new AuthenticationError(
|
||||
"You are not authenticated. Please login again.",
|
||||
response.status,
|
||||
response.statusText
|
||||
);
|
||||
case 403:
|
||||
throw new AuthorizationError(
|
||||
"You don't have permission to access this resource.",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
getDocumentsResponse,
|
||||
getDocumentTypeCountsRequest,
|
||||
getDocumentTypeCountsResponse,
|
||||
getSurfsenseDocsByChunkResponse,
|
||||
type SearchDocumentsRequest,
|
||||
searchDocumentsRequest,
|
||||
searchDocumentsResponse,
|
||||
|
|
@ -209,6 +210,17 @@ class DocumentsApiService {
|
|||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get Surfsense documentation by chunk ID
|
||||
* Used for resolving [citation:doc-XXX] citations
|
||||
*/
|
||||
getSurfsenseDocByChunk = async (chunkId: number) => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/surfsense-docs/by-chunk/${chunkId}`,
|
||||
getSurfsenseDocsByChunkResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a document
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@
|
|||
"info": "Information",
|
||||
"required": "Required",
|
||||
"optional": "Optional",
|
||||
"retry": "Retry"
|
||||
"retry": "Retry",
|
||||
"owner": "Owner",
|
||||
"shared": "Shared",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
|
|
@ -77,6 +80,45 @@
|
|||
"creating_account_btn": "Creating account...",
|
||||
"redirecting_login": "Redirecting to login page..."
|
||||
},
|
||||
"searchSpace": {
|
||||
"create_title": "Create Search Space",
|
||||
"create_description": "Create a new search space to organize your knowledge",
|
||||
"name_label": "Name",
|
||||
"name_placeholder": "Enter search space name",
|
||||
"description_label": "Description",
|
||||
"description_placeholder": "What is this search space for?",
|
||||
"create_button": "Create",
|
||||
"creating": "Creating...",
|
||||
"all_search_spaces": "All Search Spaces",
|
||||
"search_spaces_count": "{count, plural, =0 {No search spaces} =1 {1 search space} other {# search spaces}}",
|
||||
"no_search_spaces": "No search spaces yet",
|
||||
"create_first_search_space": "Create your first search space to get started",
|
||||
"members_count": "{count, plural, =1 {1 member} other {# members}}",
|
||||
"create_new_search_space": "Create new search space",
|
||||
"delete_title": "Delete Search Space",
|
||||
"delete_confirm": "Are you sure you want to delete \"{name}\"? This action cannot be undone and will permanently remove all data.",
|
||||
"welcome_title": "Welcome to SurfSense",
|
||||
"welcome_description": "Create your first search space to start organizing your knowledge, connecting sources, and chatting with AI.",
|
||||
"create_first_button": "Create your first search space"
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "User Settings",
|
||||
"description": "Manage your account settings and API access",
|
||||
"back_to_app": "Back to app",
|
||||
"footer": "User Settings",
|
||||
"api_key_nav_label": "API Key",
|
||||
"api_key_nav_description": "Manage your API access token",
|
||||
"api_key_title": "API Key",
|
||||
"api_key_description": "Use this key to authenticate API requests",
|
||||
"api_key_warning_title": "Keep it secret",
|
||||
"api_key_warning_description": "Your API key grants full access to your account. Never share it publicly or commit it to version control.",
|
||||
"your_api_key": "Your API Key",
|
||||
"copied": "Copied!",
|
||||
"copy": "Copy to clipboard",
|
||||
"no_api_key": "No API key found",
|
||||
"usage_title": "How to use",
|
||||
"usage_description": "Include your API key in the Authorization header:"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"search_spaces": "Search Spaces",
|
||||
|
|
@ -624,12 +666,13 @@
|
|||
"no_archived_chats": "No archived chats",
|
||||
"error_archiving_chat": "Failed to archive chat",
|
||||
"new_chat": "New chat",
|
||||
"select_workspace": "Select Workspace",
|
||||
"invite_members": "Invite members",
|
||||
"workspace_settings": "Workspace settings",
|
||||
"see_all_workspaces": "See all search spaces",
|
||||
"select_search_space": "Select Search Space",
|
||||
"manage_members": "Manage members",
|
||||
"search_space_settings": "Search Space settings",
|
||||
"see_all_search_spaces": "See all search spaces",
|
||||
"expand_sidebar": "Expand sidebar",
|
||||
"collapse_sidebar": "Collapse sidebar",
|
||||
"user_settings": "User settings",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"errors": {
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@
|
|||
"info": "信息",
|
||||
"required": "必填",
|
||||
"optional": "可选",
|
||||
"retry": "重试"
|
||||
"retry": "重试",
|
||||
"owner": "所有者",
|
||||
"shared": "共享",
|
||||
"settings": "设置"
|
||||
},
|
||||
"auth": {
|
||||
"login": "登录",
|
||||
|
|
@ -77,6 +80,45 @@
|
|||
"creating_account_btn": "创建中...",
|
||||
"redirecting_login": "正在跳转到登录页面..."
|
||||
},
|
||||
"searchSpace": {
|
||||
"create_title": "创建搜索空间",
|
||||
"create_description": "创建一个新的搜索空间来组织您的知识",
|
||||
"name_label": "名称",
|
||||
"name_placeholder": "输入搜索空间名称",
|
||||
"description_label": "描述",
|
||||
"description_placeholder": "这个搜索空间是做什么的?",
|
||||
"create_button": "创建",
|
||||
"creating": "创建中...",
|
||||
"all_search_spaces": "所有搜索空间",
|
||||
"search_spaces_count": "{count, plural, =0 {没有搜索空间} other {# 个搜索空间}}",
|
||||
"no_search_spaces": "暂无搜索空间",
|
||||
"create_first_search_space": "创建您的第一个搜索空间以开始使用",
|
||||
"members_count": "{count, plural, other {# 位成员}}",
|
||||
"create_new_search_space": "创建新的搜索空间",
|
||||
"delete_title": "删除搜索空间",
|
||||
"delete_confirm": "您确定要删除「{name}」吗?此操作无法撤销,将永久删除所有数据。",
|
||||
"welcome_title": "欢迎使用 SurfSense",
|
||||
"welcome_description": "创建您的第一个搜索空间,开始组织知识、连接数据源并与AI对话。",
|
||||
"create_first_button": "创建第一个搜索空间"
|
||||
},
|
||||
"userSettings": {
|
||||
"title": "用户设置",
|
||||
"description": "管理您的账户设置和API访问",
|
||||
"back_to_app": "返回应用",
|
||||
"footer": "用户设置",
|
||||
"api_key_nav_label": "API密钥",
|
||||
"api_key_nav_description": "管理您的API访问令牌",
|
||||
"api_key_title": "API密钥",
|
||||
"api_key_description": "使用此密钥验证API请求",
|
||||
"api_key_warning_title": "请保密",
|
||||
"api_key_warning_description": "您的API密钥可以完全访问您的账户。请勿公开分享或提交到版本控制。",
|
||||
"your_api_key": "您的API密钥",
|
||||
"copied": "已复制!",
|
||||
"copy": "复制到剪贴板",
|
||||
"no_api_key": "未找到API密钥",
|
||||
"usage_title": "使用方法",
|
||||
"usage_description": "在Authorization请求头中包含您的API密钥:"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "仪表盘",
|
||||
"search_spaces": "搜索空间",
|
||||
|
|
@ -618,12 +660,13 @@
|
|||
"view_all_notes": "查看所有笔记",
|
||||
"add_note": "添加笔记",
|
||||
"new_chat": "新对话",
|
||||
"select_workspace": "选择工作空间",
|
||||
"invite_members": "邀请成员",
|
||||
"workspace_settings": "工作空间设置",
|
||||
"see_all_workspaces": "查看所有搜索空间",
|
||||
"select_search_space": "选择搜索空间",
|
||||
"manage_members": "管理成员",
|
||||
"search_space_settings": "搜索空间设置",
|
||||
"see_all_search_spaces": "查看所有搜索空间",
|
||||
"expand_sidebar": "展开侧边栏",
|
||||
"collapse_sidebar": "收起侧边栏",
|
||||
"user_settings": "用户设置",
|
||||
"logout": "退出登录"
|
||||
},
|
||||
"errors": {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue