mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
Merge pull request #689 from MODSetter/dev
feat: Complete UI overhaul with docs search capabilities and MS Teams support
This commit is contained in:
commit
764dd05582
127 changed files with 6960 additions and 2539 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
|
||||
# ====================
|
||||
|
|
|
|||
|
|
@ -76,6 +76,11 @@ SLACK_CLIENT_ID=your_slack_client_id_here
|
|||
SLACK_CLIENT_SECRET=your_slack_client_secret_here
|
||||
SLACK_REDIRECT_URI=http://localhost:8000/api/v1/auth/slack/connector/callback
|
||||
|
||||
# Teams OAuth Configuration
|
||||
TEAMS_CLIENT_ID=your_teams_client_id_here
|
||||
TEAMS_CLIENT_SECRET=your_teams_client_secret_here
|
||||
TEAMS_REDIRECT_URI=http://localhost:8000/api/v1/auth/teams/connector/callback
|
||||
|
||||
# Embedding Model
|
||||
# Examples:
|
||||
# # Get sentence transformers embeddings
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
"""Add TEAMS_CONNECTOR to SearchSourceConnectorType and DocumentType enums
|
||||
|
||||
Revision ID: 59
|
||||
Revises: 58
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "59"
|
||||
down_revision: str | None = "58"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
# Define the ENUM type name and the new value
|
||||
CONNECTOR_ENUM = "searchsourceconnectortype"
|
||||
CONNECTOR_NEW_VALUE = "TEAMS_CONNECTOR"
|
||||
DOCUMENT_ENUM = "documenttype"
|
||||
DOCUMENT_NEW_VALUE = "TEAMS_CONNECTOR"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema - add TEAMS_CONNECTOR to connector and document enum safely."""
|
||||
# Add TEAMS_CONNECTOR to searchsourceconnectortype only if not exists
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum
|
||||
WHERE enumlabel = '{CONNECTOR_NEW_VALUE}'
|
||||
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = '{CONNECTOR_ENUM}')
|
||||
) THEN
|
||||
ALTER TYPE {CONNECTOR_ENUM} ADD VALUE '{CONNECTOR_NEW_VALUE}';
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Add TEAMS_CONNECTOR to documenttype only if not exists
|
||||
op.execute(
|
||||
f"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_enum
|
||||
WHERE enumlabel = '{DOCUMENT_NEW_VALUE}'
|
||||
AND enumtypid = (SELECT oid FROM pg_type WHERE typname = '{DOCUMENT_ENUM}')
|
||||
) THEN
|
||||
ALTER TYPE {DOCUMENT_ENUM} ADD VALUE '{DOCUMENT_NEW_VALUE}';
|
||||
END IF;
|
||||
END$$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema - remove TEAMS_CONNECTOR from connector and document enum."""
|
||||
|
||||
# Old enum name
|
||||
old_connector_enum_name = f"{CONNECTOR_ENUM}_old"
|
||||
old_document_enum_name = f"{DOCUMENT_ENUM}_old"
|
||||
|
||||
# All connector values except TEAMS_CONNECTOR
|
||||
old_connector_values = (
|
||||
"SERPER_API",
|
||||
"TAVILY_API",
|
||||
"SEARXNG_API",
|
||||
"LINKUP_API",
|
||||
"BAIDU_SEARCH_API",
|
||||
"SLACK_CONNECTOR",
|
||||
"NOTION_CONNECTOR",
|
||||
"GITHUB_CONNECTOR",
|
||||
"LINEAR_CONNECTOR",
|
||||
"DISCORD_CONNECTOR",
|
||||
"JIRA_CONNECTOR",
|
||||
"CONFLUENCE_CONNECTOR",
|
||||
"CLICKUP_CONNECTOR",
|
||||
"GOOGLE_CALENDAR_CONNECTOR",
|
||||
"GOOGLE_GMAIL_CONNECTOR",
|
||||
"GOOGLE_DRIVE_CONNECTOR",
|
||||
"AIRTABLE_CONNECTOR",
|
||||
"LUMA_CONNECTOR",
|
||||
"ELASTICSEARCH_CONNECTOR",
|
||||
"WEBCRAWLER_CONNECTOR",
|
||||
)
|
||||
|
||||
# All document values except TEAMS_CONNECTOR
|
||||
old_document_values = (
|
||||
"EXTENSION",
|
||||
"CRAWLED_URL",
|
||||
"FILE",
|
||||
"SLACK_CONNECTOR",
|
||||
"NOTION_CONNECTOR",
|
||||
"YOUTUBE_VIDEO",
|
||||
"GITHUB_CONNECTOR",
|
||||
"LINEAR_CONNECTOR",
|
||||
"DISCORD_CONNECTOR",
|
||||
"JIRA_CONNECTOR",
|
||||
"CONFLUENCE_CONNECTOR",
|
||||
"CLICKUP_CONNECTOR",
|
||||
"GOOGLE_CALENDAR_CONNECTOR",
|
||||
"GOOGLE_GMAIL_CONNECTOR",
|
||||
"GOOGLE_DRIVE_FILE",
|
||||
"AIRTABLE_CONNECTOR",
|
||||
"LUMA_CONNECTOR",
|
||||
"ELASTICSEARCH_CONNECTOR",
|
||||
"BOOKSTACK_CONNECTOR",
|
||||
"CIRCLEBACK",
|
||||
"NOTE",
|
||||
)
|
||||
|
||||
old_connector_values_sql = ", ".join([f"'{v}'" for v in old_connector_values])
|
||||
old_document_values_sql = ", ".join([f"'{v}'" for v in old_document_values])
|
||||
|
||||
# Table and column names
|
||||
connector_table_name = "search_source_connectors"
|
||||
connector_column_name = "connector_type"
|
||||
document_table_name = "documents"
|
||||
document_column_name = "document_type"
|
||||
|
||||
# Connector Enum Downgrade Steps
|
||||
# 1. Rename the current connector enum type
|
||||
op.execute(f"ALTER TYPE {CONNECTOR_ENUM} RENAME TO {old_connector_enum_name}")
|
||||
|
||||
# 2. Create the new connector enum type with the old values
|
||||
op.execute(f"CREATE TYPE {CONNECTOR_ENUM} AS ENUM({old_connector_values_sql})")
|
||||
|
||||
# 3. Alter the column to use the new connector enum type
|
||||
op.execute(
|
||||
f"""
|
||||
ALTER TABLE {connector_table_name}
|
||||
ALTER COLUMN {connector_column_name} TYPE {CONNECTOR_ENUM}
|
||||
USING {connector_column_name}::text::{CONNECTOR_ENUM}
|
||||
"""
|
||||
)
|
||||
|
||||
# 4. Drop the old connector enum type
|
||||
op.execute(f"DROP TYPE {old_connector_enum_name}")
|
||||
|
||||
# Document Enum Downgrade Steps
|
||||
# 1. Rename the current document enum type
|
||||
op.execute(f"ALTER TYPE {DOCUMENT_ENUM} RENAME TO {old_document_enum_name}")
|
||||
|
||||
# 2. Create the new document enum type with the old values
|
||||
op.execute(f"CREATE TYPE {DOCUMENT_ENUM} AS ENUM({old_document_values_sql})")
|
||||
|
||||
# 3. Alter the column to use the new document enum type
|
||||
op.execute(
|
||||
f"""
|
||||
ALTER TABLE {document_table_name}
|
||||
ALTER COLUMN {document_column_name} TYPE {DOCUMENT_ENUM}
|
||||
USING {document_column_name}::text::{DOCUMENT_ENUM}
|
||||
"""
|
||||
)
|
||||
|
||||
# 4. Drop the old document enum type
|
||||
op.execute(f"DROP TYPE {old_document_enum_name}")
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ _ALL_CONNECTORS: list[str] = [
|
|||
"EXTENSION",
|
||||
"FILE",
|
||||
"SLACK_CONNECTOR",
|
||||
"TEAMS_CONNECTOR",
|
||||
"NOTION_CONNECTOR",
|
||||
"YOUTUBE_VIDEO",
|
||||
"GITHUB_CONNECTOR",
|
||||
|
|
@ -573,6 +574,7 @@ def create_search_knowledge_base_tool(
|
|||
- FILE: "User-uploaded documents (PDFs, Word, etc.)" (personal files)
|
||||
- NOTE: "SurfSense Notes" (notes created inside SurfSense)
|
||||
- SLACK_CONNECTOR: "Slack conversations and shared content" (personal workspace communications)
|
||||
- TEAMS_CONNECTOR: "Microsoft Teams messages and conversations" (personal Teams communications)
|
||||
- NOTION_CONNECTOR: "Notion workspace pages and databases" (personal knowledge management)
|
||||
- YOUTUBE_VIDEO: "YouTube video transcripts and metadata" (personally saved videos)
|
||||
- GITHUB_CONNECTOR: "GitHub repository content and issues" (personal repositories and interactions)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -117,6 +117,11 @@ class Config:
|
|||
DISCORD_REDIRECT_URI = os.getenv("DISCORD_REDIRECT_URI")
|
||||
DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
|
||||
|
||||
# Microsoft Teams OAuth
|
||||
TEAMS_CLIENT_ID = os.getenv("TEAMS_CLIENT_ID")
|
||||
TEAMS_CLIENT_SECRET = os.getenv("TEAMS_CLIENT_SECRET")
|
||||
TEAMS_REDIRECT_URI = os.getenv("TEAMS_REDIRECT_URI")
|
||||
|
||||
# ClickUp OAuth
|
||||
CLICKUP_CLIENT_ID = os.getenv("CLICKUP_CLIENT_ID")
|
||||
CLICKUP_CLIENT_SECRET = os.getenv("CLICKUP_CLIENT_SECRET")
|
||||
|
|
|
|||
342
surfsense_backend/app/connectors/teams_connector.py
Normal file
342
surfsense_backend/app/connectors/teams_connector.py
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
"""
|
||||
Microsoft Teams Connector
|
||||
|
||||
A module for interacting with Microsoft Teams Graph API to retrieve teams, channels, and message history.
|
||||
|
||||
Supports OAuth-based authentication with token refresh.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from app.config import config
|
||||
from app.db import SearchSourceConnector
|
||||
from app.routes.teams_add_connector_route import refresh_teams_token
|
||||
from app.schemas.teams_auth_credentials import TeamsAuthCredentialsBase
|
||||
from app.utils.oauth_security import TokenEncryption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TeamsConnector:
|
||||
"""Class for retrieving teams, channels, and message history from Microsoft Teams."""
|
||||
|
||||
# Microsoft Graph API endpoints
|
||||
GRAPH_API_BASE = "https://graph.microsoft.com/v1.0"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
connector_id: int | None = None,
|
||||
credentials: TeamsAuthCredentialsBase | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the TeamsConnector with an access token or OAuth credentials.
|
||||
|
||||
Args:
|
||||
access_token: Microsoft Graph API access token (optional, for backward compatibility)
|
||||
session: Database session for token refresh (optional)
|
||||
connector_id: Connector ID for token refresh (optional)
|
||||
credentials: Teams OAuth credentials (optional, will be loaded from DB if not provided)
|
||||
"""
|
||||
self._session = session
|
||||
self._connector_id = connector_id
|
||||
self._credentials = credentials
|
||||
self._access_token = access_token
|
||||
|
||||
async def _get_valid_token(self) -> str:
|
||||
"""
|
||||
Get valid Microsoft Teams access token, refreshing if needed.
|
||||
|
||||
Returns:
|
||||
Valid access token
|
||||
|
||||
Raises:
|
||||
ValueError: If credentials are missing or invalid
|
||||
Exception: If token refresh fails
|
||||
"""
|
||||
# If we have a direct token (backward compatibility), use it
|
||||
if (
|
||||
self._access_token
|
||||
and self._session is None
|
||||
and self._connector_id is None
|
||||
and self._credentials is None
|
||||
):
|
||||
return self._access_token
|
||||
|
||||
# Load credentials from DB if not provided
|
||||
if self._credentials is None:
|
||||
if not self._session or not self._connector_id:
|
||||
raise ValueError(
|
||||
"Cannot load credentials: session and connector_id required"
|
||||
)
|
||||
|
||||
result = await self._session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.id == self._connector_id
|
||||
)
|
||||
)
|
||||
connector = result.scalars().first()
|
||||
|
||||
if not connector:
|
||||
raise ValueError(f"Connector {self._connector_id} not found")
|
||||
|
||||
config_data = connector.config.copy()
|
||||
|
||||
# Decrypt credentials if they are encrypted
|
||||
token_encrypted = config_data.get("_token_encrypted", False)
|
||||
if token_encrypted and config.SECRET_KEY:
|
||||
try:
|
||||
token_encryption = TokenEncryption(config.SECRET_KEY)
|
||||
|
||||
# Decrypt sensitive fields
|
||||
if config_data.get("access_token"):
|
||||
config_data["access_token"] = token_encryption.decrypt_token(
|
||||
config_data["access_token"]
|
||||
)
|
||||
if config_data.get("refresh_token"):
|
||||
config_data["refresh_token"] = token_encryption.decrypt_token(
|
||||
config_data["refresh_token"]
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Decrypted Teams credentials for connector %s",
|
||||
self._connector_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to decrypt Teams credentials for connector %s: %s",
|
||||
self._connector_id,
|
||||
str(e),
|
||||
)
|
||||
raise ValueError(
|
||||
f"Failed to decrypt Teams credentials: {e!s}"
|
||||
) from e
|
||||
|
||||
try:
|
||||
self._credentials = TeamsAuthCredentialsBase.from_dict(config_data)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid Teams credentials: {e!s}") from e
|
||||
|
||||
# Check if token is expired and refreshable
|
||||
if self._credentials.is_expired and self._credentials.is_refreshable:
|
||||
try:
|
||||
logger.info(
|
||||
"Teams token expired for connector %s, refreshing...",
|
||||
self._connector_id,
|
||||
)
|
||||
|
||||
# Get connector for refresh
|
||||
result = await self._session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.id == self._connector_id
|
||||
)
|
||||
)
|
||||
connector = result.scalars().first()
|
||||
|
||||
if not connector:
|
||||
raise RuntimeError(
|
||||
f"Connector {self._connector_id} not found; cannot refresh token."
|
||||
)
|
||||
|
||||
# Refresh token
|
||||
connector = await refresh_teams_token(self._session, connector)
|
||||
|
||||
# Reload credentials after refresh
|
||||
config_data = connector.config.copy()
|
||||
token_encrypted = config_data.get("_token_encrypted", False)
|
||||
if token_encrypted and config.SECRET_KEY:
|
||||
token_encryption = TokenEncryption(config.SECRET_KEY)
|
||||
if config_data.get("access_token"):
|
||||
config_data["access_token"] = token_encryption.decrypt_token(
|
||||
config_data["access_token"]
|
||||
)
|
||||
if config_data.get("refresh_token"):
|
||||
config_data["refresh_token"] = token_encryption.decrypt_token(
|
||||
config_data["refresh_token"]
|
||||
)
|
||||
|
||||
self._credentials = TeamsAuthCredentialsBase.from_dict(config_data)
|
||||
|
||||
logger.info(
|
||||
"Successfully refreshed Teams token for connector %s",
|
||||
self._connector_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to refresh Teams token for connector %s: %s",
|
||||
self._connector_id,
|
||||
str(e),
|
||||
)
|
||||
raise ValueError(
|
||||
f"Failed to refresh Teams OAuth credentials: {e!s}"
|
||||
) from e
|
||||
|
||||
return self._credentials.access_token
|
||||
|
||||
async def get_joined_teams(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get list of all teams the user is a member of.
|
||||
|
||||
Returns:
|
||||
List of team objects with id, display_name, etc.
|
||||
"""
|
||||
access_token = await self._get_valid_token()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.GRAPH_API_BASE}/me/joinedTeams",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ValueError(
|
||||
f"Failed to get joined teams: {response.status_code} - {response.text}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
return data.get("value", [])
|
||||
|
||||
async def get_team_channels(self, team_id: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get list of all channels in a team.
|
||||
|
||||
Args:
|
||||
team_id: The team ID
|
||||
|
||||
Returns:
|
||||
List of channel objects
|
||||
"""
|
||||
access_token = await self._get_valid_token()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{self.GRAPH_API_BASE}/teams/{team_id}/channels",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ValueError(
|
||||
f"Failed to get channels for team {team_id}: {response.status_code} - {response.text}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
return data.get("value", [])
|
||||
|
||||
async def get_channel_messages(
|
||||
self,
|
||||
team_id: str,
|
||||
channel_id: str,
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get messages from a specific channel with optional date filtering.
|
||||
|
||||
Args:
|
||||
team_id: The team ID
|
||||
channel_id: The channel ID
|
||||
start_date: Optional start date for filtering messages
|
||||
end_date: Optional end date for filtering messages
|
||||
|
||||
Returns:
|
||||
List of message objects
|
||||
"""
|
||||
access_token = await self._get_valid_token()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = (
|
||||
f"{self.GRAPH_API_BASE}/teams/{team_id}/channels/{channel_id}/messages"
|
||||
)
|
||||
|
||||
# Note: The Graph API for channel messages doesn't support $filter parameter
|
||||
# We fetch all messages and filter them client-side
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise ValueError(
|
||||
f"Failed to get messages from channel {channel_id}: {response.status_code} - {response.text}"
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
messages = data.get("value", [])
|
||||
|
||||
# Filter messages by date if needed (client-side filtering)
|
||||
if start_date or end_date:
|
||||
# Make sure comparison dates are timezone-aware (UTC)
|
||||
if start_date and start_date.tzinfo is None:
|
||||
start_date = start_date.replace(tzinfo=UTC)
|
||||
if end_date and end_date.tzinfo is None:
|
||||
end_date = end_date.replace(tzinfo=UTC)
|
||||
|
||||
filtered_messages = []
|
||||
for message in messages:
|
||||
created_at_str = message.get("createdDateTime")
|
||||
if not created_at_str:
|
||||
continue
|
||||
|
||||
# Parse the ISO 8601 datetime string (already timezone-aware)
|
||||
created_at = datetime.fromisoformat(
|
||||
created_at_str.replace("Z", "+00:00")
|
||||
)
|
||||
|
||||
# Check if message is within date range
|
||||
if start_date and created_at < start_date:
|
||||
continue
|
||||
if end_date and created_at > end_date:
|
||||
continue
|
||||
|
||||
filtered_messages.append(message)
|
||||
|
||||
return filtered_messages
|
||||
|
||||
return messages
|
||||
|
||||
async def get_message_replies(
|
||||
self, team_id: str, channel_id: str, message_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get replies to a specific message.
|
||||
|
||||
Args:
|
||||
team_id: The team ID
|
||||
channel_id: The channel ID
|
||||
message_id: The message ID
|
||||
|
||||
Returns:
|
||||
List of reply message objects
|
||||
"""
|
||||
access_token = await self._get_valid_token()
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
url = f"{self.GRAPH_API_BASE}/teams/{team_id}/channels/{channel_id}/messages/{message_id}/replies"
|
||||
|
||||
response = await client.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
logger.warning(
|
||||
"Failed to get replies for message %s: %s - %s",
|
||||
message_id,
|
||||
response.status_code,
|
||||
response.text,
|
||||
)
|
||||
return []
|
||||
|
||||
data = response.json()
|
||||
return data.get("value", [])
|
||||
254
surfsense_backend/app/connectors/teams_history.py
Normal file
254
surfsense_backend/app/connectors/teams_history.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
"""
|
||||
Microsoft Teams History Module
|
||||
|
||||
A module for retrieving conversation history from Microsoft Teams channels.
|
||||
Allows fetching team lists, channel lists, and message history with date range filtering.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.connectors.teams_connector import TeamsConnector
|
||||
from app.schemas.teams_auth_credentials import TeamsAuthCredentialsBase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TeamsHistory:
|
||||
"""Class for retrieving conversation history from Microsoft Teams channels."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str | None = None,
|
||||
session: AsyncSession | None = None,
|
||||
connector_id: int | None = None,
|
||||
credentials: TeamsAuthCredentialsBase | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the TeamsHistory class.
|
||||
|
||||
Args:
|
||||
access_token: Microsoft Graph API access token (optional, for backward compatibility)
|
||||
session: Database session for token refresh (optional)
|
||||
connector_id: Connector ID for token refresh (optional)
|
||||
credentials: Teams OAuth credentials (optional, will be loaded from DB if not provided)
|
||||
"""
|
||||
self.connector = TeamsConnector(
|
||||
access_token=access_token,
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
credentials=credentials,
|
||||
)
|
||||
|
||||
async def get_all_teams(self) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get list of all teams the user has access to.
|
||||
|
||||
Returns:
|
||||
List of team objects containing team metadata.
|
||||
"""
|
||||
try:
|
||||
teams = await self.connector.get_joined_teams()
|
||||
logger.info("Retrieved %s teams", len(teams))
|
||||
return teams
|
||||
except Exception as e:
|
||||
logger.error("Error fetching teams: %s", str(e))
|
||||
raise
|
||||
|
||||
async def get_channels_for_team(self, team_id: str) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get list of all channels in a specific team.
|
||||
|
||||
Args:
|
||||
team_id: The ID of the team
|
||||
|
||||
Returns:
|
||||
List of channel objects containing channel metadata.
|
||||
"""
|
||||
try:
|
||||
channels = await self.connector.get_team_channels(team_id)
|
||||
logger.info("Retrieved %s channels for team %s", len(channels), team_id)
|
||||
return channels
|
||||
except Exception as e:
|
||||
logger.error("Error fetching channels for team %s: %s", team_id, str(e))
|
||||
raise
|
||||
|
||||
async def get_messages_from_channel(
|
||||
self,
|
||||
team_id: str,
|
||||
channel_id: str,
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
include_replies: bool = True,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get messages from a specific channel with optional date filtering.
|
||||
|
||||
Args:
|
||||
team_id: The ID of the team
|
||||
channel_id: The ID of the channel
|
||||
start_date: Optional start date for filtering messages
|
||||
end_date: Optional end date for filtering messages
|
||||
include_replies: Whether to include reply messages (default: True)
|
||||
|
||||
Returns:
|
||||
List of message objects with content and metadata.
|
||||
"""
|
||||
try:
|
||||
messages = await self.connector.get_channel_messages(
|
||||
team_id, channel_id, start_date, end_date
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Retrieved %s messages from channel %s in team %s",
|
||||
len(messages),
|
||||
channel_id,
|
||||
team_id,
|
||||
)
|
||||
|
||||
# Fetch replies if requested
|
||||
if include_replies:
|
||||
all_messages = []
|
||||
for message in messages:
|
||||
all_messages.append(message)
|
||||
# Get replies for this message
|
||||
try:
|
||||
replies = await self.connector.get_message_replies(
|
||||
team_id, channel_id, message.get("id")
|
||||
)
|
||||
all_messages.extend(replies)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to get replies for message %s",
|
||||
message.get("id"),
|
||||
exc_info=True,
|
||||
)
|
||||
# Continue without replies for this message
|
||||
|
||||
logger.info(
|
||||
"Total messages including replies: %s for channel %s",
|
||||
len(all_messages),
|
||||
channel_id,
|
||||
)
|
||||
return all_messages
|
||||
|
||||
return messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error fetching messages from channel %s in team %s: %s",
|
||||
channel_id,
|
||||
team_id,
|
||||
str(e),
|
||||
)
|
||||
raise
|
||||
|
||||
async def get_all_messages_from_team(
|
||||
self,
|
||||
team_id: str,
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
include_replies: bool = True,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""
|
||||
Get all messages from all channels in a team.
|
||||
|
||||
Args:
|
||||
team_id: The ID of the team
|
||||
start_date: Optional start date for filtering messages
|
||||
end_date: Optional end date for filtering messages
|
||||
include_replies: Whether to include reply messages (default: True)
|
||||
|
||||
Returns:
|
||||
Dictionary mapping channel IDs to lists of messages.
|
||||
"""
|
||||
try:
|
||||
channels = await self.get_channels_for_team(team_id)
|
||||
all_channel_messages = {}
|
||||
|
||||
for channel in channels:
|
||||
channel_id = channel.get("id")
|
||||
channel_name = channel.get("displayName", "Unknown")
|
||||
|
||||
try:
|
||||
messages = await self.get_messages_from_channel(
|
||||
team_id, channel_id, start_date, end_date, include_replies
|
||||
)
|
||||
all_channel_messages[channel_id] = messages
|
||||
logger.info(
|
||||
"Fetched %s messages from channel '%s' (%s)",
|
||||
len(messages),
|
||||
channel_name,
|
||||
channel_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Failed to fetch messages from channel '%s' (%s)",
|
||||
channel_name,
|
||||
channel_id,
|
||||
exc_info=True,
|
||||
)
|
||||
all_channel_messages[channel_id] = []
|
||||
|
||||
return all_channel_messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error fetching messages from team %s: %s", team_id, str(e))
|
||||
raise
|
||||
|
||||
async def get_all_messages(
|
||||
self,
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
include_replies: bool = True,
|
||||
) -> dict[str, dict[str, list[dict[str, Any]]]]:
|
||||
"""
|
||||
Get all messages from all teams and channels the user has access to.
|
||||
|
||||
Args:
|
||||
start_date: Optional start date for filtering messages
|
||||
end_date: Optional end date for filtering messages
|
||||
include_replies: Whether to include reply messages (default: True)
|
||||
|
||||
Returns:
|
||||
Nested dictionary: team_id -> channel_id -> list of messages.
|
||||
"""
|
||||
try:
|
||||
teams = await self.get_all_teams()
|
||||
all_messages = {}
|
||||
|
||||
for team in teams:
|
||||
team_id = team.get("id")
|
||||
team_name = team.get("displayName", "Unknown")
|
||||
|
||||
try:
|
||||
team_messages = await self.get_all_messages_from_team(
|
||||
team_id, start_date, end_date, include_replies
|
||||
)
|
||||
all_messages[team_id] = team_messages
|
||||
total_messages = sum(
|
||||
len(messages) for messages in team_messages.values()
|
||||
)
|
||||
logger.info(
|
||||
"Fetched %s total messages from team '%s' (%s)",
|
||||
total_messages,
|
||||
team_name,
|
||||
team_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Failed to fetch messages from team '%s' (%s)",
|
||||
team_name,
|
||||
team_id,
|
||||
exc_info=True,
|
||||
)
|
||||
all_messages[team_id] = {}
|
||||
|
||||
return all_messages
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error fetching all messages: %s", str(e))
|
||||
raise
|
||||
|
|
@ -36,6 +36,7 @@ class DocumentType(str, Enum):
|
|||
CRAWLED_URL = "CRAWLED_URL"
|
||||
FILE = "FILE"
|
||||
SLACK_CONNECTOR = "SLACK_CONNECTOR"
|
||||
TEAMS_CONNECTOR = "TEAMS_CONNECTOR"
|
||||
NOTION_CONNECTOR = "NOTION_CONNECTOR"
|
||||
YOUTUBE_VIDEO = "YOUTUBE_VIDEO"
|
||||
GITHUB_CONNECTOR = "GITHUB_CONNECTOR"
|
||||
|
|
@ -62,6 +63,7 @@ class SearchSourceConnectorType(str, Enum):
|
|||
LINKUP_API = "LINKUP_API"
|
||||
BAIDU_SEARCH_API = "BAIDU_SEARCH_API" # Baidu AI Search API for Chinese web search
|
||||
SLACK_CONNECTOR = "SLACK_CONNECTOR"
|
||||
TEAMS_CONNECTOR = "TEAMS_CONNECTOR"
|
||||
NOTION_CONNECTOR = "NOTION_CONNECTOR"
|
||||
GITHUB_CONNECTOR = "GITHUB_CONNECTOR"
|
||||
LINEAR_CONNECTOR = "LINEAR_CONNECTOR"
|
||||
|
|
@ -426,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,8 @@ 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()
|
||||
|
||||
|
|
@ -50,6 +52,7 @@ router.include_router(linear_add_connector_router)
|
|||
router.include_router(luma_add_connector_router)
|
||||
router.include_router(notion_add_connector_router)
|
||||
router.include_router(slack_add_connector_router)
|
||||
router.include_router(teams_add_connector_router)
|
||||
router.include_router(discord_add_connector_router)
|
||||
router.include_router(jira_add_connector_router)
|
||||
router.include_router(confluence_add_connector_router)
|
||||
|
|
@ -57,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
|
||||
|
|
|
|||
|
|
@ -543,7 +543,7 @@ async def index_connector_content(
|
|||
),
|
||||
end_date: str = Query(
|
||||
None,
|
||||
description="End date for indexing (YYYY-MM-DD format). If not provided, uses today's date",
|
||||
description="End date for indexing (YYYY-MM-DD format). If not provided, uses today's date. For calendar connectors (Google Calendar, Luma), future dates can be selected to index upcoming events.",
|
||||
),
|
||||
drive_items: GoogleDriveIndexRequest | None = Body(
|
||||
None,
|
||||
|
|
@ -558,6 +558,7 @@ async def index_connector_content(
|
|||
|
||||
Currently supports:
|
||||
- SLACK_CONNECTOR: Indexes messages from all accessible Slack channels
|
||||
- TEAMS_CONNECTOR: Indexes messages from all accessible Microsoft Teams channels
|
||||
- NOTION_CONNECTOR: Indexes pages from all accessible Notion pages
|
||||
- GITHUB_CONNECTOR: Indexes code and documentation from GitHub repositories
|
||||
- LINEAR_CONNECTOR: Indexes issues and comments from Linear
|
||||
|
|
@ -616,7 +617,16 @@ async def index_connector_content(
|
|||
else:
|
||||
indexing_from = start_date
|
||||
|
||||
indexing_to = end_date if end_date else today_str
|
||||
# For calendar connectors, default to today but allow future dates if explicitly provided
|
||||
if connector.connector_type in [
|
||||
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
|
||||
SearchSourceConnectorType.LUMA_CONNECTOR,
|
||||
]:
|
||||
# Default to today if no end_date provided (users can manually select future dates)
|
||||
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
|
||||
|
||||
if connector.connector_type == SearchSourceConnectorType.SLACK_CONNECTOR:
|
||||
from app.tasks.celery_tasks.connector_tasks import (
|
||||
|
|
@ -631,6 +641,19 @@ async def index_connector_content(
|
|||
)
|
||||
response_message = "Slack indexing started in the background."
|
||||
|
||||
elif connector.connector_type == SearchSourceConnectorType.TEAMS_CONNECTOR:
|
||||
from app.tasks.celery_tasks.connector_tasks import (
|
||||
index_teams_messages_task,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Triggering Teams indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
|
||||
)
|
||||
index_teams_messages_task.delay(
|
||||
connector_id, search_space_id, str(user.id), indexing_from, indexing_to
|
||||
)
|
||||
response_message = "Teams indexing started in the background."
|
||||
|
||||
elif connector.connector_type == SearchSourceConnectorType.NOTION_CONNECTOR:
|
||||
from app.tasks.celery_tasks.connector_tasks import index_notion_pages_task
|
||||
|
||||
|
|
@ -857,9 +880,10 @@ async def index_connector_content(
|
|||
) from e
|
||||
|
||||
|
||||
async def update_connector_last_indexed(session: AsyncSession, connector_id: int):
|
||||
async def _update_connector_timestamp_by_id(session: AsyncSession, connector_id: int):
|
||||
"""
|
||||
Update the last_indexed_at timestamp for a connector.
|
||||
Update the last_indexed_at timestamp for a connector by its ID.
|
||||
Internal helper function for routes.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
|
|
@ -934,7 +958,7 @@ async def run_slack_indexing(
|
|||
|
||||
# Only update last_indexed_at if indexing was successful (either new docs or updated docs)
|
||||
if documents_processed > 0:
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
logger.info(
|
||||
f"Slack indexing completed successfully: {documents_processed} documents processed"
|
||||
)
|
||||
|
|
@ -996,7 +1020,7 @@ async def run_notion_indexing(
|
|||
|
||||
# Only update last_indexed_at if indexing was successful (either new docs or updated docs)
|
||||
if documents_processed > 0:
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
logger.info(
|
||||
f"Notion indexing completed successfully: {documents_processed} documents processed"
|
||||
)
|
||||
|
|
@ -1056,7 +1080,7 @@ async def run_github_indexing(
|
|||
f"GitHub indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
|
||||
)
|
||||
# Update the last indexed timestamp only on success
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
await session.commit() # Commit timestamp update
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
|
|
@ -1115,7 +1139,7 @@ async def run_linear_indexing(
|
|||
f"Linear indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
|
||||
)
|
||||
# Update the last indexed timestamp only on success
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
await session.commit() # Commit timestamp update
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
|
|
@ -1176,7 +1200,7 @@ async def run_discord_indexing(
|
|||
|
||||
# Only update last_indexed_at if indexing was successful (either new docs or updated docs)
|
||||
if documents_processed > 0:
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
logger.info(
|
||||
f"Discord indexing completed successfully: {documents_processed} documents processed"
|
||||
)
|
||||
|
|
@ -1188,6 +1212,64 @@ async def run_discord_indexing(
|
|||
logger.error(f"Error in background Discord indexing task: {e!s}")
|
||||
|
||||
|
||||
async def run_teams_indexing_with_new_session(
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
):
|
||||
"""
|
||||
Create a new session and run the Microsoft Teams indexing task.
|
||||
This prevents session leaks by creating a dedicated session for the background task.
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
await run_teams_indexing(
|
||||
session, connector_id, search_space_id, user_id, start_date, end_date
|
||||
)
|
||||
|
||||
|
||||
async def run_teams_indexing(
|
||||
session: AsyncSession,
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
):
|
||||
"""
|
||||
Background task to run Microsoft Teams indexing.
|
||||
Args:
|
||||
session: Database session
|
||||
connector_id: ID of the Teams connector
|
||||
search_space_id: ID of the search space
|
||||
user_id: ID of the user
|
||||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
try:
|
||||
from app.tasks.connector_indexers.teams_indexer import index_teams_messages
|
||||
|
||||
# Index Teams messages without updating last_indexed_at (we'll do it separately)
|
||||
documents_processed, error_or_warning = await index_teams_messages(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
search_space_id=search_space_id,
|
||||
user_id=user_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
update_last_indexed=False, # Don't update timestamp in the indexing function
|
||||
)
|
||||
|
||||
# Update last_indexed_at after successful indexing (even if 0 new docs - they were checked)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
logger.info(
|
||||
f"Teams indexing completed successfully: {documents_processed} documents processed. {error_or_warning or ''}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in background Teams indexing task: {e!s}")
|
||||
|
||||
|
||||
# Add new helper functions for Jira indexing
|
||||
async def run_jira_indexing_with_new_session(
|
||||
connector_id: int,
|
||||
|
|
@ -1236,7 +1318,7 @@ async def run_jira_indexing(
|
|||
f"Jira indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
|
||||
)
|
||||
# Update the last indexed timestamp only on success
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
await session.commit() # Commit timestamp update
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
|
@ -1296,7 +1378,7 @@ async def run_confluence_indexing(
|
|||
f"Confluence indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
|
||||
)
|
||||
# Update the last indexed timestamp only on success
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
await session.commit() # Commit timestamp update
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
|
@ -1354,7 +1436,7 @@ async def run_clickup_indexing(
|
|||
f"ClickUp indexing successful for connector {connector_id}. Indexed {indexed_count} tasks."
|
||||
)
|
||||
# Update the last indexed timestamp only on success
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
await session.commit() # Commit timestamp update
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
|
@ -1412,7 +1494,7 @@ async def run_airtable_indexing(
|
|||
f"Airtable indexing successful for connector {connector_id}. Indexed {indexed_count} records."
|
||||
)
|
||||
# Update the last indexed timestamp only on success
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
await session.commit() # Commit timestamp update
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
|
@ -1472,7 +1554,7 @@ async def run_google_calendar_indexing(
|
|||
f"Google Calendar indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
|
||||
)
|
||||
# Update the last indexed timestamp only on success
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
await session.commit() # Commit timestamp update
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
|
@ -1539,7 +1621,7 @@ async def run_google_gmail_indexing(
|
|||
f"Google Gmail indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
|
||||
)
|
||||
# Update the last indexed timestamp only on success
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
await session.commit() # Commit timestamp update
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
|
@ -1623,7 +1705,7 @@ async def run_google_drive_indexing(
|
|||
f"Google Drive indexing successful for connector {connector_id}. Indexed {total_indexed} documents from {len(items.folders)} folder(s) and {len(items.files)} file(s)."
|
||||
)
|
||||
# Update the last indexed timestamp only on full success
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
await session.commit() # Commit timestamp update
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
|
@ -1683,7 +1765,7 @@ async def run_luma_indexing(
|
|||
|
||||
# Only update last_indexed_at if indexing was successful (either new docs or updated docs)
|
||||
if documents_processed > 0:
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
logger.info(
|
||||
f"Luma indexing completed successfully: {documents_processed} documents processed"
|
||||
)
|
||||
|
|
@ -1743,7 +1825,7 @@ async def run_elasticsearch_indexing(
|
|||
f"Elasticsearch indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
|
||||
)
|
||||
# Update the last indexed timestamp only on success
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
|
|
@ -1802,7 +1884,7 @@ async def run_web_page_indexing(
|
|||
|
||||
# Only update last_indexed_at if indexing was successful (either new docs or updated docs)
|
||||
if documents_processed > 0:
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
logger.info(
|
||||
f"Web page indexing completed successfully: {documents_processed} documents processed"
|
||||
)
|
||||
|
|
@ -1875,7 +1957,7 @@ async def run_bookstack_indexing(
|
|||
f"BookStack indexing successful for connector {connector_id}. Indexed {indexed_count} documents."
|
||||
)
|
||||
# Update the last indexed timestamp only on success
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
await _update_connector_timestamp_by_id(session, connector_id)
|
||||
await session.commit() # Commit timestamp update
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
|
|
|||
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
|
||||
478
surfsense_backend/app/routes/teams_add_connector_route.py
Normal file
478
surfsense_backend/app/routes/teams_add_connector_route.py
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
"""
|
||||
Microsoft Teams Connector OAuth Routes.
|
||||
|
||||
Handles OAuth 2.0 authentication flow for Microsoft Teams connector using Microsoft Graph API.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import config
|
||||
from app.db import (
|
||||
SearchSourceConnector,
|
||||
SearchSourceConnectorType,
|
||||
User,
|
||||
get_async_session,
|
||||
)
|
||||
from app.schemas.teams_auth_credentials import TeamsAuthCredentialsBase
|
||||
from app.users import current_active_user
|
||||
from app.utils.connector_naming import (
|
||||
check_duplicate_connector,
|
||||
extract_identifier_from_credentials,
|
||||
generate_unique_connector_name,
|
||||
)
|
||||
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Microsoft identity platform endpoints
|
||||
AUTHORIZATION_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize"
|
||||
TOKEN_URL = "https://login.microsoftonline.com/common/oauth2/v2.0/token"
|
||||
|
||||
# OAuth scopes for Microsoft Teams (Graph API)
|
||||
SCOPES = [
|
||||
"offline_access", # Required for refresh tokens
|
||||
"User.Read", # Read user profile
|
||||
"Team.ReadBasic.All", # Read basic team information
|
||||
"Channel.ReadBasic.All", # Read basic channel information
|
||||
"ChannelMessage.Read.All", # Read messages in channels
|
||||
]
|
||||
|
||||
# Initialize security utilities
|
||||
_state_manager = None
|
||||
_token_encryption = None
|
||||
|
||||
|
||||
def get_state_manager() -> OAuthStateManager:
|
||||
"""Get or create OAuth state manager instance."""
|
||||
global _state_manager
|
||||
if _state_manager is None:
|
||||
if not config.SECRET_KEY:
|
||||
raise ValueError("SECRET_KEY must be set for OAuth security")
|
||||
_state_manager = OAuthStateManager(config.SECRET_KEY)
|
||||
return _state_manager
|
||||
|
||||
|
||||
def get_token_encryption() -> TokenEncryption:
|
||||
"""Get or create token encryption instance."""
|
||||
global _token_encryption
|
||||
if _token_encryption is None:
|
||||
if not config.SECRET_KEY:
|
||||
raise ValueError("SECRET_KEY must be set for token encryption")
|
||||
_token_encryption = TokenEncryption(config.SECRET_KEY)
|
||||
return _token_encryption
|
||||
|
||||
|
||||
@router.get("/auth/teams/connector/add")
|
||||
async def connect_teams(space_id: int, user: User = Depends(current_active_user)):
|
||||
"""
|
||||
Initiate Microsoft Teams OAuth flow.
|
||||
|
||||
Args:
|
||||
space_id: The search space ID
|
||||
user: Current authenticated user
|
||||
|
||||
Returns:
|
||||
Authorization URL for redirect
|
||||
"""
|
||||
try:
|
||||
if not space_id:
|
||||
raise HTTPException(status_code=400, detail="space_id is required")
|
||||
|
||||
if not config.TEAMS_CLIENT_ID:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Microsoft Teams OAuth not configured."
|
||||
)
|
||||
|
||||
if not config.SECRET_KEY:
|
||||
raise HTTPException(
|
||||
status_code=500, detail="SECRET_KEY not configured for OAuth security."
|
||||
)
|
||||
|
||||
# Generate secure state parameter with HMAC signature
|
||||
state_manager = get_state_manager()
|
||||
state_encoded = state_manager.generate_secure_state(space_id, user.id)
|
||||
|
||||
# Build authorization URL
|
||||
from urllib.parse import urlencode
|
||||
|
||||
auth_params = {
|
||||
"client_id": config.TEAMS_CLIENT_ID,
|
||||
"response_type": "code",
|
||||
"redirect_uri": config.TEAMS_REDIRECT_URI,
|
||||
"response_mode": "query",
|
||||
"scope": " ".join(SCOPES),
|
||||
"state": state_encoded,
|
||||
}
|
||||
|
||||
auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}"
|
||||
|
||||
logger.info(
|
||||
"Generated Microsoft Teams OAuth URL for user %s, space %s",
|
||||
user.id,
|
||||
space_id,
|
||||
)
|
||||
return {"auth_url": auth_url}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to initiate Microsoft Teams OAuth: %s", str(e), exc_info=True
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to initiate Microsoft Teams OAuth: {e!s}",
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/auth/teams/connector/callback")
|
||||
async def teams_callback(
|
||||
code: str | None = None,
|
||||
error: str | None = None,
|
||||
error_description: str | None = None,
|
||||
state: str | None = None,
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Handle Microsoft Teams OAuth callback.
|
||||
|
||||
Args:
|
||||
code: Authorization code from Microsoft (if user granted access)
|
||||
error: Error code from Microsoft (if user denied access or error occurred)
|
||||
error_description: Human-readable error description
|
||||
state: State parameter containing user/space info
|
||||
session: Database session
|
||||
|
||||
Returns:
|
||||
Redirect response to frontend
|
||||
"""
|
||||
try:
|
||||
# Handle OAuth errors (e.g., user denied access)
|
||||
if error:
|
||||
error_msg = error_description or error
|
||||
logger.warning("Microsoft Teams OAuth error: %s", error_msg)
|
||||
redirect_url = f"{config.NEXT_FRONTEND_URL}/dashboard?error=teams_auth_failed&message={error_msg}"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
# Validate required parameters
|
||||
if not code or not state:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Missing required OAuth parameters"
|
||||
)
|
||||
|
||||
# Verify and decode state parameter
|
||||
state_manager = get_state_manager()
|
||||
try:
|
||||
data = state_manager.validate_state(state)
|
||||
space_id = data["space_id"]
|
||||
user_id = UUID(data["user_id"])
|
||||
except (HTTPException, ValueError, KeyError) as e:
|
||||
logger.error("Invalid OAuth state: %s", str(e))
|
||||
redirect_url = f"{config.NEXT_FRONTEND_URL}/dashboard?error=invalid_state"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
# Exchange authorization code for access token
|
||||
token_data = {
|
||||
"client_id": config.TEAMS_CLIENT_ID,
|
||||
"client_secret": config.TEAMS_CLIENT_SECRET,
|
||||
"code": code,
|
||||
"redirect_uri": config.TEAMS_REDIRECT_URI,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
TOKEN_URL,
|
||||
data=token_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
error_detail = token_response.text
|
||||
try:
|
||||
error_json = token_response.json()
|
||||
error_detail = error_json.get("error_description", error_detail)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Token exchange failed: {error_detail}"
|
||||
)
|
||||
|
||||
token_json = token_response.json()
|
||||
|
||||
# Extract tokens from response
|
||||
access_token = token_json.get("access_token")
|
||||
refresh_token = token_json.get("refresh_token")
|
||||
|
||||
if not access_token:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No access token received from Microsoft"
|
||||
)
|
||||
|
||||
# Encrypt sensitive tokens before storing
|
||||
token_encryption = get_token_encryption()
|
||||
|
||||
# Calculate expiration time (UTC, tz-aware)
|
||||
expires_at = None
|
||||
if token_json.get("expires_in"):
|
||||
now_utc = datetime.now(UTC)
|
||||
expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"]))
|
||||
|
||||
# Fetch user info from Microsoft Graph API
|
||||
user_info = {}
|
||||
tenant_info = {}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
# Get user profile
|
||||
user_response = await client.get(
|
||||
"https://graph.microsoft.com/v1.0/me",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
if user_response.status_code == 200:
|
||||
user_data = user_response.json()
|
||||
user_info = {
|
||||
"user_id": user_data.get("id"),
|
||||
"user_name": user_data.get("displayName"),
|
||||
"user_email": user_data.get("mail")
|
||||
or user_data.get("userPrincipalName"),
|
||||
}
|
||||
|
||||
# Get organization/tenant info
|
||||
org_response = await client.get(
|
||||
"https://graph.microsoft.com/v1.0/organization",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
if org_response.status_code == 200:
|
||||
org_data = org_response.json()
|
||||
if org_data.get("value") and len(org_data["value"]) > 0:
|
||||
org = org_data["value"][0]
|
||||
tenant_info = {
|
||||
"tenant_id": org.get("id"),
|
||||
"tenant_name": org.get("displayName"),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to fetch user/tenant info from Microsoft Graph: %s", str(e)
|
||||
)
|
||||
|
||||
# Store the encrypted tokens and user/tenant info in connector config
|
||||
connector_config = {
|
||||
"access_token": token_encryption.encrypt_token(access_token),
|
||||
"refresh_token": token_encryption.encrypt_token(refresh_token)
|
||||
if refresh_token
|
||||
else None,
|
||||
"token_type": token_json.get("token_type", "Bearer"),
|
||||
"expires_in": token_json.get("expires_in"),
|
||||
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||
"scope": token_json.get("scope"),
|
||||
"tenant_id": tenant_info.get("tenant_id"),
|
||||
"tenant_name": tenant_info.get("tenant_name"),
|
||||
"user_id": user_info.get("user_id"),
|
||||
# Mark that token is encrypted for backward compatibility
|
||||
"_token_encrypted": True,
|
||||
}
|
||||
|
||||
# Extract unique identifier from connector credentials
|
||||
connector_identifier = extract_identifier_from_credentials(
|
||||
SearchSourceConnectorType.TEAMS_CONNECTOR, connector_config
|
||||
)
|
||||
|
||||
# Check for duplicate connector (same tenant already connected)
|
||||
is_duplicate = await check_duplicate_connector(
|
||||
session,
|
||||
SearchSourceConnectorType.TEAMS_CONNECTOR,
|
||||
space_id,
|
||||
user_id,
|
||||
connector_identifier,
|
||||
)
|
||||
|
||||
if is_duplicate:
|
||||
logger.warning(
|
||||
"Duplicate Microsoft Teams connector for user %s, space %s, tenant %s",
|
||||
user_id,
|
||||
space_id,
|
||||
tenant_info.get("tenant_name"),
|
||||
)
|
||||
redirect_url = f"{config.NEXT_FRONTEND_URL}/dashboard?error=duplicate_connector&message=This Microsoft Teams tenant is already connected to this space"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
# Generate unique connector name
|
||||
connector_name = await generate_unique_connector_name(
|
||||
session,
|
||||
SearchSourceConnectorType.TEAMS_CONNECTOR,
|
||||
space_id,
|
||||
user_id,
|
||||
connector_identifier,
|
||||
)
|
||||
|
||||
# Create new connector
|
||||
new_connector = SearchSourceConnector(
|
||||
name=connector_name,
|
||||
connector_type=SearchSourceConnectorType.TEAMS_CONNECTOR,
|
||||
is_indexable=True,
|
||||
config=connector_config,
|
||||
search_space_id=space_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
try:
|
||||
session.add(new_connector)
|
||||
await session.commit()
|
||||
await session.refresh(new_connector)
|
||||
|
||||
logger.info(
|
||||
"Successfully created Microsoft Teams connector %s for user %s",
|
||||
new_connector.id,
|
||||
user_id,
|
||||
)
|
||||
|
||||
# Redirect to frontend with success
|
||||
redirect_url = f"{config.NEXT_FRONTEND_URL}/dashboard?success=teams_connected&connector_id={new_connector.id}"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
except IntegrityError as e:
|
||||
await session.rollback()
|
||||
logger.error(
|
||||
"Database integrity error creating Teams connector: %s", str(e)
|
||||
)
|
||||
redirect_url = (
|
||||
f"{config.NEXT_FRONTEND_URL}/dashboard?error=connector_creation_failed"
|
||||
)
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except (IntegrityError, ValueError) as e:
|
||||
logger.error("Teams OAuth callback error: %s", str(e), exc_info=True)
|
||||
redirect_url = f"{config.NEXT_FRONTEND_URL}/dashboard?error=teams_auth_error"
|
||||
return RedirectResponse(url=redirect_url)
|
||||
|
||||
|
||||
async def refresh_teams_token(
|
||||
session: AsyncSession, connector: SearchSourceConnector
|
||||
) -> SearchSourceConnector:
|
||||
"""
|
||||
Refresh Microsoft Teams OAuth tokens.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
connector: The connector to refresh
|
||||
|
||||
Returns:
|
||||
Updated connector with refreshed tokens
|
||||
|
||||
Raises:
|
||||
HTTPException: If token refresh fails
|
||||
"""
|
||||
logger.info(
|
||||
"Refreshing Microsoft Teams OAuth tokens for connector %s", connector.id
|
||||
)
|
||||
|
||||
credentials = TeamsAuthCredentialsBase.from_dict(connector.config)
|
||||
|
||||
# Decrypt tokens if they are encrypted
|
||||
token_encryption = get_token_encryption()
|
||||
is_encrypted = connector.config.get("_token_encrypted", False)
|
||||
refresh_token = credentials.refresh_token
|
||||
|
||||
if is_encrypted and refresh_token:
|
||||
try:
|
||||
refresh_token = token_encryption.decrypt_token(refresh_token)
|
||||
except Exception as e:
|
||||
logger.error("Failed to decrypt refresh token: %s", str(e))
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Failed to decrypt stored refresh token"
|
||||
) from e
|
||||
|
||||
if not refresh_token:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"No refresh token available for connector {connector.id}",
|
||||
)
|
||||
|
||||
# Microsoft uses oauth2/v2.0/token for token refresh
|
||||
refresh_data = {
|
||||
"client_id": config.TEAMS_CLIENT_ID,
|
||||
"client_secret": config.TEAMS_CLIENT_SECRET,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"scope": " ".join(SCOPES),
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
TOKEN_URL,
|
||||
data=refresh_data,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
error_detail = token_response.text
|
||||
try:
|
||||
error_json = token_response.json()
|
||||
error_detail = error_json.get("error_description", error_detail)
|
||||
except Exception:
|
||||
pass
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Token refresh failed: {error_detail}"
|
||||
)
|
||||
|
||||
token_json = token_response.json()
|
||||
|
||||
# Extract new tokens
|
||||
access_token = token_json.get("access_token")
|
||||
new_refresh_token = token_json.get("refresh_token")
|
||||
|
||||
if not access_token:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="No access token received from Microsoft refresh"
|
||||
)
|
||||
|
||||
# Calculate expiration time (UTC, tz-aware)
|
||||
expires_at = None
|
||||
expires_in = token_json.get("expires_in")
|
||||
if expires_in:
|
||||
now_utc = datetime.now(UTC)
|
||||
expires_at = now_utc + timedelta(seconds=int(expires_in))
|
||||
|
||||
# Update credentials object with encrypted tokens
|
||||
credentials.access_token = token_encryption.encrypt_token(access_token)
|
||||
if new_refresh_token:
|
||||
credentials.refresh_token = token_encryption.encrypt_token(new_refresh_token)
|
||||
credentials.expires_in = expires_in
|
||||
credentials.expires_at = expires_at
|
||||
credentials.scope = token_json.get("scope")
|
||||
|
||||
# Preserve tenant/user info
|
||||
if not credentials.tenant_id:
|
||||
credentials.tenant_id = connector.config.get("tenant_id")
|
||||
if not credentials.tenant_name:
|
||||
credentials.tenant_name = connector.config.get("tenant_name")
|
||||
if not credentials.user_id:
|
||||
credentials.user_id = connector.config.get("user_id")
|
||||
|
||||
# Update connector config with encrypted tokens
|
||||
credentials_dict = credentials.to_dict()
|
||||
credentials_dict["_token_encrypted"] = True
|
||||
connector.config = credentials_dict
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(connector)
|
||||
|
||||
logger.info(
|
||||
"Successfully refreshed Microsoft Teams tokens for connector %s", connector.id
|
||||
)
|
||||
|
||||
return connector
|
||||
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)
|
||||
79
surfsense_backend/app/schemas/teams_auth_credentials.py
Normal file
79
surfsense_backend/app/schemas/teams_auth_credentials.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""
|
||||
Microsoft Teams OAuth credentials schema.
|
||||
"""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
|
||||
class TeamsAuthCredentialsBase(BaseModel):
|
||||
"""Microsoft Teams OAuth credentials."""
|
||||
|
||||
access_token: str
|
||||
refresh_token: str | None = None
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int | None = None
|
||||
expires_at: datetime | None = None
|
||||
scope: str | None = None
|
||||
tenant_id: str | None = None
|
||||
tenant_name: str | None = None
|
||||
user_id: str | None = None
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""Check if the credentials have expired."""
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
return self.expires_at <= datetime.now(UTC)
|
||||
|
||||
@property
|
||||
def is_refreshable(self) -> bool:
|
||||
"""Check if the credentials can be refreshed."""
|
||||
return self.refresh_token is not None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert credentials to dictionary for storage."""
|
||||
return {
|
||||
"access_token": self.access_token,
|
||||
"refresh_token": self.refresh_token,
|
||||
"token_type": self.token_type,
|
||||
"expires_in": self.expires_in,
|
||||
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
|
||||
"scope": self.scope,
|
||||
"tenant_id": self.tenant_id,
|
||||
"tenant_name": self.tenant_name,
|
||||
"user_id": self.user_id,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "TeamsAuthCredentialsBase":
|
||||
"""Create credentials from dictionary."""
|
||||
expires_at = None
|
||||
if data.get("expires_at"):
|
||||
expires_at = datetime.fromisoformat(data["expires_at"])
|
||||
|
||||
return cls(
|
||||
access_token=data.get("access_token", ""),
|
||||
refresh_token=data.get("refresh_token"),
|
||||
token_type=data.get("token_type", "Bearer"),
|
||||
expires_in=data.get("expires_in"),
|
||||
expires_at=expires_at,
|
||||
scope=data.get("scope"),
|
||||
tenant_id=data.get("tenant_id"),
|
||||
tenant_name=data.get("tenant_name"),
|
||||
user_id=data.get("user_id"),
|
||||
)
|
||||
|
||||
@field_validator("expires_at", mode="before")
|
||||
@classmethod
|
||||
def ensure_aware_utc(cls, v):
|
||||
"""Ensure datetime is timezone-aware (UTC)."""
|
||||
if isinstance(v, str):
|
||||
if v.endswith("Z"):
|
||||
return datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||
dt = datetime.fromisoformat(v)
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=UTC)
|
||||
if isinstance(v, datetime):
|
||||
return v if v.tzinfo else v.replace(tzinfo=UTC)
|
||||
return v
|
||||
|
|
@ -2269,6 +2269,80 @@ class ConnectorService:
|
|||
|
||||
return result_object, discord_docs
|
||||
|
||||
async def search_teams(
|
||||
self,
|
||||
user_query: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
start_date: datetime | None = None,
|
||||
end_date: datetime | None = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Search for Microsoft Teams messages and return both the source information and langchain documents.
|
||||
|
||||
Uses combined chunk-level and document-level hybrid search with RRF fusion.
|
||||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
start_date: Optional start date for filtering documents by updated_at
|
||||
end_date: Optional end date for filtering documents by updated_at
|
||||
|
||||
Returns:
|
||||
tuple: (sources_info, langchain_documents)
|
||||
"""
|
||||
teams_docs = await self._combined_rrf_search(
|
||||
query_text=user_query,
|
||||
search_space_id=search_space_id,
|
||||
document_type="TEAMS_CONNECTOR",
|
||||
top_k=top_k,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
)
|
||||
|
||||
# Early return if no results
|
||||
if not teams_docs:
|
||||
return {
|
||||
"id": 53,
|
||||
"name": "Microsoft Teams",
|
||||
"type": "TEAMS_CONNECTOR",
|
||||
"sources": [],
|
||||
}, []
|
||||
|
||||
def _title_fn(_doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||
team_name = metadata.get("team_name", "Unknown Team")
|
||||
channel_name = metadata.get("channel_name", "Unknown Channel")
|
||||
message_date = metadata.get("start_date", "")
|
||||
title = f"Teams: {team_name} - {channel_name}"
|
||||
if message_date:
|
||||
title += f" ({message_date})"
|
||||
return title
|
||||
|
||||
def _url_fn(_doc_info: dict[str, Any], metadata: dict[str, Any]) -> str:
|
||||
team_id = metadata.get("team_id", "")
|
||||
channel_id = metadata.get("channel_id", "")
|
||||
if team_id and channel_id:
|
||||
return f"https://teams.microsoft.com/l/channel/{channel_id}/General?groupId={team_id}"
|
||||
return ""
|
||||
|
||||
sources_list = self._build_chunk_sources_from_documents(
|
||||
teams_docs,
|
||||
title_fn=_title_fn,
|
||||
url_fn=_url_fn,
|
||||
description_fn=lambda chunk, _doc_info, _metadata: chunk.get("content", ""),
|
||||
)
|
||||
|
||||
# Create result object
|
||||
result_object = {
|
||||
"id": 53,
|
||||
"name": "Microsoft Teams",
|
||||
"type": "TEAMS_CONNECTOR",
|
||||
"sources": sources_list,
|
||||
}
|
||||
|
||||
return result_object, teams_docs
|
||||
|
||||
async def search_luma(
|
||||
self,
|
||||
user_query: str,
|
||||
|
|
|
|||
|
|
@ -564,6 +564,49 @@ async def _index_discord_messages(
|
|||
)
|
||||
|
||||
|
||||
@celery_app.task(name="index_teams_messages", bind=True)
|
||||
def index_teams_messages_task(
|
||||
self,
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
):
|
||||
"""Celery task to index Microsoft Teams messages."""
|
||||
import asyncio
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
try:
|
||||
loop.run_until_complete(
|
||||
_index_teams_messages(
|
||||
connector_id, search_space_id, user_id, start_date, end_date
|
||||
)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
async def _index_teams_messages(
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
):
|
||||
"""Index Microsoft Teams messages with new session."""
|
||||
from app.routes.search_source_connectors_routes import (
|
||||
run_teams_indexing,
|
||||
)
|
||||
|
||||
async with get_celery_session_maker()() as session:
|
||||
await run_teams_indexing(
|
||||
session, connector_id, search_space_id, user_id, start_date, end_date
|
||||
)
|
||||
|
||||
|
||||
@celery_app.task(name="index_luma_events", bind=True)
|
||||
def index_luma_events_task(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -45,8 +45,9 @@ async def index_google_calendar_events(
|
|||
connector_id: ID of the Google Calendar connector
|
||||
search_space_id: ID of the search space to store documents in
|
||||
user_id: User ID
|
||||
start_date: Start date for indexing (YYYY-MM-DD format)
|
||||
end_date: End date for indexing (YYYY-MM-DD format)
|
||||
start_date: Start date for indexing (YYYY-MM-DD format). Can be in the past or future.
|
||||
end_date: End date for indexing (YYYY-MM-DD format). Can be in the future to index upcoming events.
|
||||
Defaults to today if not provided.
|
||||
update_last_indexed: Whether to update the last_indexed_at timestamp (default: True)
|
||||
|
||||
Returns:
|
||||
|
|
@ -165,8 +166,10 @@ async def index_google_calendar_events(
|
|||
end_date = None
|
||||
|
||||
# Calculate date range
|
||||
# For calendar connectors, allow future dates to index upcoming events
|
||||
if start_date is None or end_date is None:
|
||||
# Fall back to calculating dates based on last_indexed_at
|
||||
# Default to today (users can manually select future dates if needed)
|
||||
calculated_end_date = datetime.now()
|
||||
|
||||
# Use last_indexed_at as start date if available, otherwise use 30 days ago
|
||||
|
|
@ -178,19 +181,13 @@ async def index_google_calendar_events(
|
|||
else connector.last_indexed_at
|
||||
)
|
||||
|
||||
# Check if last_indexed_at is in the future or after end_date
|
||||
if last_indexed_naive > calculated_end_date:
|
||||
logger.warning(
|
||||
f"Last indexed date ({last_indexed_naive.strftime('%Y-%m-%d')}) is in the future. Using 30 days ago instead."
|
||||
)
|
||||
calculated_start_date = calculated_end_date - timedelta(days=30)
|
||||
else:
|
||||
calculated_start_date = last_indexed_naive
|
||||
logger.info(
|
||||
f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date"
|
||||
)
|
||||
# Allow future dates - use last_indexed_at as start date
|
||||
calculated_start_date = last_indexed_naive
|
||||
logger.info(
|
||||
f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date"
|
||||
)
|
||||
else:
|
||||
calculated_start_date = calculated_end_date - timedelta(
|
||||
calculated_start_date = datetime.now() - timedelta(
|
||||
days=30
|
||||
) # Use 30 days as default for calendar events
|
||||
logger.info(
|
||||
|
|
@ -205,7 +202,7 @@ async def index_google_calendar_events(
|
|||
end_date if end_date else calculated_end_date.strftime("%Y-%m-%d")
|
||||
)
|
||||
else:
|
||||
# Use provided dates
|
||||
# Use provided dates (including future dates)
|
||||
start_date_str = start_date
|
||||
end_date_str = end_date
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,9 @@ async def index_luma_events(
|
|||
connector_id: ID of the Luma connector
|
||||
search_space_id: ID of the search space to store documents in
|
||||
user_id: User ID
|
||||
start_date: Start date for indexing (YYYY-MM-DD format)
|
||||
end_date: End date for indexing (YYYY-MM-DD format)
|
||||
start_date: Start date for indexing (YYYY-MM-DD format). Can be in the past or future.
|
||||
end_date: End date for indexing (YYYY-MM-DD format). Can be in the future to index upcoming events.
|
||||
Defaults to today if not provided.
|
||||
update_last_indexed: Whether to update the last_indexed_at timestamp (default: True)
|
||||
|
||||
Returns:
|
||||
|
|
@ -116,8 +117,10 @@ async def index_luma_events(
|
|||
luma_client = LumaConnector(api_key=api_key)
|
||||
|
||||
# Calculate date range
|
||||
# For calendar connectors, allow future dates to index upcoming events
|
||||
if start_date is None or end_date is None:
|
||||
# Fall back to calculating dates based on last_indexed_at
|
||||
# Default to today (users can manually select future dates if needed)
|
||||
calculated_end_date = datetime.now()
|
||||
|
||||
# Use last_indexed_at as start date if available, otherwise use 30 days ago
|
||||
|
|
@ -129,19 +132,13 @@ async def index_luma_events(
|
|||
else connector.last_indexed_at
|
||||
)
|
||||
|
||||
# Check if last_indexed_at is in the future or after end_date
|
||||
if last_indexed_naive > calculated_end_date:
|
||||
logger.warning(
|
||||
f"Last indexed date ({last_indexed_naive.strftime('%Y-%m-%d')}) is in the future. Using 30 days ago instead."
|
||||
)
|
||||
calculated_start_date = calculated_end_date - timedelta(days=30)
|
||||
else:
|
||||
calculated_start_date = last_indexed_naive
|
||||
logger.info(
|
||||
f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date"
|
||||
)
|
||||
# Allow future dates - use last_indexed_at as start date
|
||||
calculated_start_date = last_indexed_naive
|
||||
logger.info(
|
||||
f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date"
|
||||
)
|
||||
else:
|
||||
calculated_start_date = calculated_end_date - timedelta(days=30)
|
||||
calculated_start_date = datetime.now() - timedelta(days=30)
|
||||
logger.info(
|
||||
f"No last_indexed_at found, using {calculated_start_date.strftime('%Y-%m-%d')} (30 days ago) as start date"
|
||||
)
|
||||
|
|
@ -154,7 +151,7 @@ async def index_luma_events(
|
|||
end_date if end_date else calculated_end_date.strftime("%Y-%m-%d")
|
||||
)
|
||||
else:
|
||||
# Use provided dates
|
||||
# Use provided dates (including future dates)
|
||||
start_date_str = start_date
|
||||
end_date_str = end_date
|
||||
|
||||
|
|
|
|||
484
surfsense_backend/app/tasks/connector_indexers/teams_indexer.py
Normal file
484
surfsense_backend/app/tasks/connector_indexers/teams_indexer.py
Normal file
|
|
@ -0,0 +1,484 @@
|
|||
"""
|
||||
Microsoft Teams connector indexer.
|
||||
"""
|
||||
|
||||
from datetime import UTC
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import config
|
||||
from app.connectors.teams_history import TeamsHistory
|
||||
from app.db import Document, DocumentType, SearchSourceConnectorType
|
||||
from app.services.task_logging_service import TaskLoggingService
|
||||
from app.utils.document_converters import (
|
||||
create_document_chunks,
|
||||
generate_content_hash,
|
||||
generate_unique_identifier_hash,
|
||||
)
|
||||
|
||||
from .base import (
|
||||
build_document_metadata_markdown,
|
||||
calculate_date_range,
|
||||
check_document_by_unique_identifier,
|
||||
get_connector_by_id,
|
||||
get_current_timestamp,
|
||||
logger,
|
||||
update_connector_last_indexed,
|
||||
)
|
||||
|
||||
|
||||
async def index_teams_messages(
|
||||
session: AsyncSession,
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
update_last_indexed: bool = True,
|
||||
) -> tuple[int, str | None]:
|
||||
"""
|
||||
Index Microsoft Teams messages from all accessible teams and channels.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
connector_id: ID of the Teams connector
|
||||
search_space_id: ID of the search space to store documents in
|
||||
user_id: ID of the user
|
||||
start_date: Start date for indexing (YYYY-MM-DD format)
|
||||
end_date: End date for indexing (YYYY-MM-DD format)
|
||||
update_last_indexed: Whether to update the last_indexed_at timestamp (default: True)
|
||||
|
||||
Returns:
|
||||
Tuple containing (number of documents indexed, error message or None)
|
||||
"""
|
||||
task_logger = TaskLoggingService(session, search_space_id)
|
||||
|
||||
# Log task start
|
||||
log_entry = await task_logger.log_task_start(
|
||||
task_name="teams_messages_indexing",
|
||||
source="connector_indexing_task",
|
||||
message=f"Starting Microsoft Teams messages indexing for connector {connector_id}",
|
||||
metadata={
|
||||
"connector_id": connector_id,
|
||||
"user_id": str(user_id),
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Get the connector
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Retrieving Teams connector {connector_id} from database",
|
||||
{"stage": "connector_retrieval"},
|
||||
)
|
||||
|
||||
connector = await get_connector_by_id(
|
||||
session, connector_id, SearchSourceConnectorType.TEAMS_CONNECTOR
|
||||
)
|
||||
|
||||
if not connector:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Connector with ID {connector_id} not found or is not a Teams connector",
|
||||
"Connector not found",
|
||||
{"error_type": "ConnectorNotFound"},
|
||||
)
|
||||
return (
|
||||
0,
|
||||
f"Connector with ID {connector_id} not found or is not a Teams connector",
|
||||
)
|
||||
|
||||
# Initialize Teams client with auto-refresh support
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Initializing Teams client for connector {connector_id}",
|
||||
{"stage": "client_initialization"},
|
||||
)
|
||||
|
||||
teams_client = TeamsHistory(session=session, connector_id=connector_id)
|
||||
|
||||
# Handle 'undefined' string from frontend (treat as None)
|
||||
if start_date == "undefined" or start_date == "":
|
||||
start_date = None
|
||||
if end_date == "undefined" or end_date == "":
|
||||
end_date = None
|
||||
|
||||
# Calculate date range
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
"Calculating date range for Teams indexing",
|
||||
{
|
||||
"stage": "date_calculation",
|
||||
"provided_start_date": start_date,
|
||||
"provided_end_date": end_date,
|
||||
},
|
||||
)
|
||||
|
||||
start_date_str, end_date_str = calculate_date_range(
|
||||
connector, start_date, end_date, default_days_back=365
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Indexing Teams messages from %s to %s", start_date_str, end_date_str
|
||||
)
|
||||
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Fetching Teams from {start_date_str} to {end_date_str}",
|
||||
{
|
||||
"stage": "fetch_teams",
|
||||
"start_date": start_date_str,
|
||||
"end_date": end_date_str,
|
||||
},
|
||||
)
|
||||
|
||||
# Get all teams
|
||||
try:
|
||||
teams = await teams_client.get_all_teams()
|
||||
except Exception as e:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Failed to get Teams for connector {connector_id}",
|
||||
str(e),
|
||||
{"error_type": "TeamsFetchError"},
|
||||
)
|
||||
return 0, f"Failed to get Teams: {e!s}"
|
||||
|
||||
if not teams:
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
f"No Teams found for connector {connector_id}",
|
||||
{"teams_found": 0},
|
||||
)
|
||||
return 0, "No Teams found"
|
||||
|
||||
# Track the number of documents indexed
|
||||
documents_indexed = 0
|
||||
documents_skipped = 0
|
||||
skipped_channels = []
|
||||
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Starting to process {len(teams)} Teams",
|
||||
{"stage": "process_teams", "total_teams": len(teams)},
|
||||
)
|
||||
|
||||
# Convert date strings to datetime objects for filtering
|
||||
from datetime import datetime
|
||||
|
||||
start_datetime = None
|
||||
end_datetime = None
|
||||
if start_date_str:
|
||||
# Parse as naive datetime and make it timezone-aware (UTC)
|
||||
start_datetime = datetime.strptime(start_date_str, "%Y-%m-%d").replace(
|
||||
tzinfo=UTC
|
||||
)
|
||||
if end_date_str:
|
||||
# Parse as naive datetime, set to end of day, and make it timezone-aware (UTC)
|
||||
end_datetime = datetime.strptime(end_date_str, "%Y-%m-%d").replace(
|
||||
hour=23, minute=59, second=59, tzinfo=UTC
|
||||
)
|
||||
|
||||
# Process each team
|
||||
for team in teams:
|
||||
team_id = team.get("id")
|
||||
team_name = team.get("displayName", "Unknown Team")
|
||||
|
||||
try:
|
||||
# Get channels for this team
|
||||
channels = await teams_client.get_channels_for_team(team_id)
|
||||
|
||||
if not channels:
|
||||
logger.info("No channels found in team %s", team_name)
|
||||
continue
|
||||
|
||||
# Process each channel in the team
|
||||
for channel in channels:
|
||||
channel_id = channel.get("id")
|
||||
channel_name = channel.get("displayName", "Unknown Channel")
|
||||
|
||||
try:
|
||||
# Get messages for this channel
|
||||
messages = await teams_client.get_messages_from_channel(
|
||||
team_id,
|
||||
channel_id,
|
||||
start_datetime,
|
||||
end_datetime,
|
||||
include_replies=True,
|
||||
)
|
||||
|
||||
if not messages:
|
||||
logger.info(
|
||||
"No messages found in channel %s of team %s for the specified date range.",
|
||||
channel_name,
|
||||
team_name,
|
||||
)
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Process each message
|
||||
for msg in messages:
|
||||
# Skip deleted messages or empty content
|
||||
if msg.get("deletedDateTime"):
|
||||
continue
|
||||
|
||||
# Extract message details
|
||||
message_id = msg.get("id", "")
|
||||
created_datetime = msg.get("createdDateTime", "")
|
||||
from_user = msg.get("from", {})
|
||||
user_name = from_user.get("user", {}).get(
|
||||
"displayName", "Unknown User"
|
||||
)
|
||||
user_email = from_user.get("user", {}).get(
|
||||
"userPrincipalName", "Unknown Email"
|
||||
)
|
||||
|
||||
# Extract message content
|
||||
body = msg.get("body", {})
|
||||
content_type = body.get("contentType", "text")
|
||||
msg_text = body.get("content", "")
|
||||
|
||||
# Skip empty messages
|
||||
if not msg_text or msg_text.strip() == "":
|
||||
continue
|
||||
|
||||
# Format document metadata
|
||||
metadata_sections = [
|
||||
(
|
||||
"METADATA",
|
||||
[
|
||||
f"TEAM_NAME: {team_name}",
|
||||
f"TEAM_ID: {team_id}",
|
||||
f"CHANNEL_NAME: {channel_name}",
|
||||
f"CHANNEL_ID: {channel_id}",
|
||||
f"MESSAGE_TIMESTAMP: {created_datetime}",
|
||||
f"MESSAGE_USER_NAME: {user_name}",
|
||||
f"MESSAGE_USER_EMAIL: {user_email}",
|
||||
f"CONTENT_TYPE: {content_type}",
|
||||
],
|
||||
),
|
||||
(
|
||||
"CONTENT",
|
||||
[
|
||||
f"FORMAT: {content_type}",
|
||||
"TEXT_START",
|
||||
msg_text,
|
||||
"TEXT_END",
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
# Build the document string
|
||||
combined_document_string = build_document_metadata_markdown(
|
||||
metadata_sections
|
||||
)
|
||||
|
||||
# Generate unique identifier hash for this Teams message
|
||||
unique_identifier = f"{team_id}_{channel_id}_{message_id}"
|
||||
unique_identifier_hash = generate_unique_identifier_hash(
|
||||
DocumentType.TEAMS_CONNECTOR,
|
||||
unique_identifier,
|
||||
search_space_id,
|
||||
)
|
||||
|
||||
# Generate content hash
|
||||
content_hash = generate_content_hash(
|
||||
combined_document_string, search_space_id
|
||||
)
|
||||
|
||||
# Check if document with this unique identifier already exists
|
||||
existing_document = (
|
||||
await check_document_by_unique_identifier(
|
||||
session, unique_identifier_hash
|
||||
)
|
||||
)
|
||||
|
||||
if existing_document:
|
||||
# Document exists - check if content has changed
|
||||
if existing_document.content_hash == content_hash:
|
||||
logger.info(
|
||||
"Document for Teams message %s in channel %s unchanged. Skipping.",
|
||||
message_id,
|
||||
channel_name,
|
||||
)
|
||||
documents_skipped += 1
|
||||
continue
|
||||
else:
|
||||
# Content has changed - update the existing document
|
||||
logger.info(
|
||||
"Content changed for Teams message %s in channel %s. Updating document.",
|
||||
message_id,
|
||||
channel_name,
|
||||
)
|
||||
|
||||
# Update chunks and embedding
|
||||
chunks = await create_document_chunks(
|
||||
combined_document_string
|
||||
)
|
||||
doc_embedding = (
|
||||
config.embedding_model_instance.embed(
|
||||
combined_document_string
|
||||
)
|
||||
)
|
||||
|
||||
# Update existing document
|
||||
existing_document.content = combined_document_string
|
||||
existing_document.content_hash = content_hash
|
||||
existing_document.embedding = doc_embedding
|
||||
existing_document.document_metadata = {
|
||||
"team_name": team_name,
|
||||
"team_id": team_id,
|
||||
"channel_name": channel_name,
|
||||
"channel_id": channel_id,
|
||||
"start_date": start_date_str,
|
||||
"end_date": end_date_str,
|
||||
"message_count": len(messages),
|
||||
"indexed_at": datetime.now().strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
}
|
||||
|
||||
# Delete old chunks and add new ones
|
||||
existing_document.chunks = chunks
|
||||
existing_document.updated_at = (
|
||||
get_current_timestamp()
|
||||
)
|
||||
|
||||
documents_indexed += 1
|
||||
logger.info(
|
||||
"Successfully updated Teams message %s",
|
||||
message_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# Document doesn't exist - create new one
|
||||
# Process chunks
|
||||
chunks = await create_document_chunks(
|
||||
combined_document_string
|
||||
)
|
||||
doc_embedding = config.embedding_model_instance.embed(
|
||||
combined_document_string
|
||||
)
|
||||
|
||||
# Create and store new document
|
||||
document = Document(
|
||||
search_space_id=search_space_id,
|
||||
title=f"Teams - {team_name} - {channel_name}",
|
||||
document_type=DocumentType.TEAMS_CONNECTOR,
|
||||
document_metadata={
|
||||
"team_name": team_name,
|
||||
"team_id": team_id,
|
||||
"channel_name": channel_name,
|
||||
"channel_id": channel_id,
|
||||
"start_date": start_date_str,
|
||||
"end_date": end_date_str,
|
||||
"message_count": len(messages),
|
||||
"indexed_at": datetime.now().strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
),
|
||||
},
|
||||
content=combined_document_string,
|
||||
embedding=doc_embedding,
|
||||
chunks=chunks,
|
||||
content_hash=content_hash,
|
||||
unique_identifier_hash=unique_identifier_hash,
|
||||
updated_at=get_current_timestamp(),
|
||||
)
|
||||
|
||||
session.add(document)
|
||||
documents_indexed += 1
|
||||
|
||||
# Batch commit every 10 documents
|
||||
if documents_indexed % 10 == 0:
|
||||
logger.info(
|
||||
"Committing batch: %s Teams messages processed so far",
|
||||
documents_indexed,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Successfully indexed channel %s in team %s with %s messages",
|
||||
channel_name,
|
||||
team_name,
|
||||
len(messages),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Error processing channel %s in team %s: %s",
|
||||
channel_name,
|
||||
team_name,
|
||||
str(e),
|
||||
)
|
||||
skipped_channels.append(
|
||||
f"{team_name}/{channel_name} (processing error)"
|
||||
)
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing team %s: %s", team_name, str(e))
|
||||
continue
|
||||
|
||||
# Update the last_indexed_at timestamp for the connector only if requested
|
||||
# and if we successfully indexed at least one document
|
||||
total_processed = documents_indexed
|
||||
if total_processed > 0:
|
||||
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||
|
||||
# Final commit for any remaining documents not yet committed in batches
|
||||
logger.info(
|
||||
"Final commit: Total %s Teams messages processed", documents_indexed
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
# Prepare result message
|
||||
result_message = None
|
||||
if skipped_channels:
|
||||
result_message = f"Processed {total_processed} messages. Skipped {len(skipped_channels)} channels: {', '.join(skipped_channels)}"
|
||||
else:
|
||||
result_message = f"Processed {total_processed} messages."
|
||||
|
||||
# Log success
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
f"Successfully completed Teams indexing for connector {connector_id}",
|
||||
{
|
||||
"messages_processed": total_processed,
|
||||
"documents_indexed": documents_indexed,
|
||||
"documents_skipped": documents_skipped,
|
||||
"skipped_channels_count": len(skipped_channels),
|
||||
"result_message": result_message,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Teams indexing completed: %s new messages, %s skipped",
|
||||
documents_indexed,
|
||||
documents_skipped,
|
||||
)
|
||||
return total_processed, result_message
|
||||
|
||||
except SQLAlchemyError as db_error:
|
||||
await session.rollback()
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Database error during Teams indexing for connector {connector_id}",
|
||||
str(db_error),
|
||||
{"error_type": "SQLAlchemyError"},
|
||||
)
|
||||
logger.error("Database error: %s", str(db_error))
|
||||
return 0, f"Database error: {db_error!s}"
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Failed to index Teams messages for connector {connector_id}",
|
||||
str(e),
|
||||
{"error_type": type(e).__name__},
|
||||
)
|
||||
logger.error("Failed to index Teams messages: %s", str(e))
|
||||
return 0, f"Failed to index Teams messages: {e!s}"
|
||||
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
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import logging
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends, Request, Response
|
||||
|
|
@ -12,7 +13,17 @@ from fastapi_users.db import SQLAlchemyUserDatabase
|
|||
from pydantic import BaseModel
|
||||
|
||||
from app.config import config
|
||||
from app.db import User, get_user_db
|
||||
from app.db import (
|
||||
SearchSpace,
|
||||
SearchSpaceMembership,
|
||||
SearchSpaceRole,
|
||||
User,
|
||||
async_session_maker,
|
||||
get_default_roles_config,
|
||||
get_user_db,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BearerResponse(BaseModel):
|
||||
|
|
@ -36,7 +47,59 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
|
|||
verification_token_secret = SECRET
|
||||
|
||||
async def on_after_register(self, user: User, request: Request | None = None):
|
||||
print(f"User {user.id} has registered.")
|
||||
"""
|
||||
Called after a user registers. Creates a default search space for the user
|
||||
so they can start chatting immediately without manual setup.
|
||||
"""
|
||||
logger.info(f"User {user.id} has registered. Creating default search space...")
|
||||
|
||||
try:
|
||||
async with async_session_maker() as session:
|
||||
# Create default search space
|
||||
default_search_space = SearchSpace(
|
||||
name="My Search Space",
|
||||
description="Your personal search space",
|
||||
user_id=user.id,
|
||||
)
|
||||
session.add(default_search_space)
|
||||
await session.flush() # Get the search space ID
|
||||
|
||||
# Create default roles
|
||||
default_roles = get_default_roles_config()
|
||||
owner_role_id = None
|
||||
|
||||
for role_config in default_roles:
|
||||
db_role = SearchSpaceRole(
|
||||
name=role_config["name"],
|
||||
description=role_config["description"],
|
||||
permissions=role_config["permissions"],
|
||||
is_default=role_config["is_default"],
|
||||
is_system_role=role_config["is_system_role"],
|
||||
search_space_id=default_search_space.id,
|
||||
)
|
||||
session.add(db_role)
|
||||
await session.flush()
|
||||
|
||||
if role_config["name"] == "Owner":
|
||||
owner_role_id = db_role.id
|
||||
|
||||
# Create owner membership
|
||||
owner_membership = SearchSpaceMembership(
|
||||
user_id=user.id,
|
||||
search_space_id=default_search_space.id,
|
||||
role_id=owner_role_id,
|
||||
is_owner=True,
|
||||
)
|
||||
session.add(owner_membership)
|
||||
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Created default search space (ID: {default_search_space.id}) for user {user.id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to create default search space for user {user.id}: {e}"
|
||||
)
|
||||
|
||||
async def on_after_forgot_password(
|
||||
self, user: User, token: str, request: Request | None = None
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ BASE_NAME_FOR_TYPE = {
|
|||
SearchSourceConnectorType.GOOGLE_DRIVE_CONNECTOR: "Google Drive",
|
||||
SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||
SearchSourceConnectorType.SLACK_CONNECTOR: "Slack",
|
||||
SearchSourceConnectorType.TEAMS_CONNECTOR: "Microsoft Teams",
|
||||
SearchSourceConnectorType.NOTION_CONNECTOR: "Notion",
|
||||
SearchSourceConnectorType.LINEAR_CONNECTOR: "Linear",
|
||||
SearchSourceConnectorType.JIRA_CONNECTOR: "Jira",
|
||||
|
|
@ -53,6 +54,9 @@ def extract_identifier_from_credentials(
|
|||
if connector_type == SearchSourceConnectorType.SLACK_CONNECTOR:
|
||||
return credentials.get("team_name")
|
||||
|
||||
if connector_type == SearchSourceConnectorType.TEAMS_CONNECTOR:
|
||||
return credentials.get("tenant_name")
|
||||
|
||||
if connector_type == SearchSourceConnectorType.NOTION_CONNECTOR:
|
||||
return credentials.get("workspace_name")
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ logger = logging.getLogger(__name__)
|
|||
# Mapping of connector types to their corresponding Celery task names
|
||||
CONNECTOR_TASK_MAP = {
|
||||
SearchSourceConnectorType.SLACK_CONNECTOR: "index_slack_messages",
|
||||
SearchSourceConnectorType.TEAMS_CONNECTOR: "index_teams_messages",
|
||||
SearchSourceConnectorType.NOTION_CONNECTOR: "index_notion_pages",
|
||||
SearchSourceConnectorType.GITHUB_CONNECTOR: "index_github_repos",
|
||||
SearchSourceConnectorType.LINEAR_CONNECTOR: "index_linear_issues",
|
||||
|
|
|
|||
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()
|
||||
|
|
@ -5,7 +5,7 @@ import { Navbar } from "@/components/homepage/navbar";
|
|||
|
||||
export default function HomePageLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
|
||||
<main className="min-h-screen bg-linear-to-b from-gray-50 to-gray-100 text-gray-900 dark:from-black dark:to-gray-900 dark:text-white overflow-x-hidden">
|
||||
<Navbar />
|
||||
{children}
|
||||
<FooterNew />
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Loader2 } from "lucide-react";
|
|||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
|
|
@ -17,22 +17,18 @@ import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-quer
|
|||
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
|
||||
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { LayoutDataProvider } from "@/components/layout";
|
||||
import { OnboardingTour } from "@/components/onboarding-tour";
|
||||
import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||
|
||||
export function DashboardClientLayout({
|
||||
children,
|
||||
searchSpaceId,
|
||||
navSecondary,
|
||||
navMain,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
searchSpaceId: string;
|
||||
navSecondary: any[];
|
||||
navMain: any[];
|
||||
navSecondary?: any[];
|
||||
navMain?: any[];
|
||||
}) {
|
||||
const t = useTranslations("dashboard");
|
||||
const router = useRouter();
|
||||
|
|
@ -59,50 +55,15 @@ export function DashboardClientLayout({
|
|||
const [isAutoConfiguring, setIsAutoConfiguring] = useState(false);
|
||||
const hasAttemptedAutoConfig = useRef(false);
|
||||
|
||||
// Skip onboarding check if we're already on the onboarding page
|
||||
const isOnboardingPage = pathname?.includes("/onboard");
|
||||
|
||||
// Only owners should see onboarding - invited members use existing config
|
||||
const isOwner = access?.is_owner ?? false;
|
||||
|
||||
// Translate navigation items
|
||||
const tNavMenu = useTranslations("nav_menu");
|
||||
const translatedNavMain = useMemo(() => {
|
||||
return navMain.map((item) => ({
|
||||
...item,
|
||||
title: tNavMenu(item.title.toLowerCase().replace(/ /g, "_")),
|
||||
items: item.items?.map((subItem: any) => ({
|
||||
...subItem,
|
||||
title: tNavMenu(subItem.title.toLowerCase().replace(/ /g, "_")),
|
||||
})),
|
||||
}));
|
||||
}, [navMain, tNavMenu]);
|
||||
|
||||
const translatedNavSecondary = useMemo(() => {
|
||||
return navSecondary.map((item) => ({
|
||||
...item,
|
||||
title: item.title === "All Search Spaces" ? tNavMenu("all_search_spaces") : item.title,
|
||||
}));
|
||||
}, [navSecondary, tNavMenu]);
|
||||
|
||||
const [open, setOpen] = useState<boolean>(() => {
|
||||
try {
|
||||
const match = document.cookie.match(/(?:^|; )sidebar_state=([^;]+)/);
|
||||
if (match) return match[1] === "true";
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Skip check if already on onboarding page
|
||||
if (isOnboardingPage) {
|
||||
setHasCheckedOnboarding(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for all data to load
|
||||
if (
|
||||
!loading &&
|
||||
!accessLoading &&
|
||||
|
|
@ -112,19 +73,16 @@ export function DashboardClientLayout({
|
|||
) {
|
||||
const onboardingComplete = isOnboardingComplete();
|
||||
|
||||
// If onboarding is complete, nothing to do
|
||||
if (onboardingComplete) {
|
||||
setHasCheckedOnboarding(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only handle onboarding for owners
|
||||
if (!isOwner) {
|
||||
setHasCheckedOnboarding(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If global configs available, auto-configure without going to onboard page
|
||||
if (globalConfigs.length > 0 && !hasAttemptedAutoConfig.current) {
|
||||
hasAttemptedAutoConfig.current = true;
|
||||
setIsAutoConfiguring(true);
|
||||
|
|
@ -149,7 +107,6 @@ export function DashboardClientLayout({
|
|||
setHasCheckedOnboarding(true);
|
||||
} catch (error) {
|
||||
console.error("Auto-configuration failed:", error);
|
||||
// Fall back to onboard page
|
||||
router.push(`/dashboard/${searchSpaceId}/onboard`);
|
||||
} finally {
|
||||
setIsAutoConfiguring(false);
|
||||
|
|
@ -160,7 +117,6 @@ export function DashboardClientLayout({
|
|||
return;
|
||||
}
|
||||
|
||||
// No global configs - redirect to onboard page
|
||||
router.push(`/dashboard/${searchSpaceId}/onboard`);
|
||||
setHasCheckedOnboarding(true);
|
||||
}
|
||||
|
|
@ -180,7 +136,6 @@ export function DashboardClientLayout({
|
|||
refetchPreferences,
|
||||
]);
|
||||
|
||||
// Synchronize active search space and chat IDs with URL
|
||||
useEffect(() => {
|
||||
const activeSeacrhSpaceId =
|
||||
typeof search_space_id === "string"
|
||||
|
|
@ -192,7 +147,6 @@ export function DashboardClientLayout({
|
|||
setActiveSearchSpaceIdState(activeSeacrhSpaceId);
|
||||
}, [search_space_id, setActiveSearchSpaceIdState]);
|
||||
|
||||
// Show loading screen while checking onboarding status or auto-configuring
|
||||
if (
|
||||
(!hasCheckedOnboarding &&
|
||||
(loading || accessLoading || globalConfigsLoading) &&
|
||||
|
|
@ -220,7 +174,6 @@ export function DashboardClientLayout({
|
|||
);
|
||||
}
|
||||
|
||||
// Show error screen if there's an error loading preferences (but not on onboarding page)
|
||||
if (error && !hasCheckedOnboarding && !isOnboardingPage) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen space-y-4">
|
||||
|
|
@ -244,33 +197,13 @@ export function DashboardClientLayout({
|
|||
return (
|
||||
<DocumentUploadDialogProvider>
|
||||
<OnboardingTour />
|
||||
<SidebarProvider className="h-full overflow-hidden" open={open} onOpenChange={setOpen}>
|
||||
{/* Use AppSidebarProvider which fetches user, search space, and recent chats */}
|
||||
<AppSidebarProvider
|
||||
searchSpaceId={searchSpaceId}
|
||||
navSecondary={translatedNavSecondary}
|
||||
navMain={translatedNavMain}
|
||||
/>
|
||||
<SidebarInset className="h-full ">
|
||||
<main className="flex flex-col h-full">
|
||||
<header className="sticky top-0 flex h-16 shrink-0 items-center gap-2 bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 border-b">
|
||||
<div className="flex items-center justify-between w-full gap-2 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<DashboardBreadcrumb />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 overflow-hidden">{children}</div>
|
||||
</main>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
<LayoutDataProvider
|
||||
searchSpaceId={searchSpaceId}
|
||||
breadcrumb={<DashboardBreadcrumb />}
|
||||
languageSwitcher={<LanguageSwitcher />}
|
||||
>
|
||||
{children}
|
||||
</LayoutDataProvider>
|
||||
</DocumentUploadDialogProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,31 +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 { 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,
|
||||
|
|
@ -35,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 }}
|
||||
|
|
@ -71,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>
|
||||
|
|
@ -83,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" />
|
||||
|
|
@ -108,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>
|
||||
|
|
@ -124,258 +86,62 @@ const ErrorScreen = ({ message }: { message: string }) => {
|
|||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const DashboardPage = () => {
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
// 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);
|
||||
|
||||
// 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.png", // Default avatar
|
||||
};
|
||||
|
||||
if (loading) 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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
|
@ -379,7 +379,7 @@ export default function InviteAcceptPage() {
|
|||
href="/"
|
||||
className="inline-flex items-center gap-2 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Image src="/icon-128.png" alt="SurfSense" width={24} height={24} className="rounded" />
|
||||
<Image src="/icon-128.svg" alt="SurfSense" width={24} height={24} className="rounded" />
|
||||
<span className="text-sm font-medium">SurfSense</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -14,159 +14,165 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||
{
|
||||
url: "https://www.surfsense.com/",
|
||||
lastModified,
|
||||
changeFrequency: "yearly",
|
||||
changeFrequency: "daily",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/contact",
|
||||
lastModified,
|
||||
changeFrequency: "yearly",
|
||||
priority: 1,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/pricing",
|
||||
lastModified,
|
||||
changeFrequency: "yearly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/privacy",
|
||||
lastModified,
|
||||
changeFrequency: "monthly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/terms",
|
||||
lastModified,
|
||||
changeFrequency: "monthly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
},
|
||||
// Documentation pages
|
||||
{
|
||||
url: "https://www.surfsense.com/docs",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
changeFrequency: "daily",
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/installation",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/docker-installation",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/manual-installation",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.9,
|
||||
},
|
||||
// Connector documentation
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/airtable",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/bookstack",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/circleback",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/clickup",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/confluence",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/discord",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/elasticsearch",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/github",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/gmail",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/google-calendar",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/google-drive",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/jira",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/linear",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/luma",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/microsoft-teams",
|
||||
lastModified,
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/notion",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/slack",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: "https://www.surfsense.com/docs/connectors/web-crawler",
|
||||
lastModified,
|
||||
changeFrequency: "weekly",
|
||||
changeFrequency: "daily",
|
||||
priority: 0.8,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@ import { cn } from "@/lib/utils";
|
|||
export const Logo = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<Link href="/">
|
||||
<Image src="/icon-128.png" className={cn(className)} alt="logo" width={128} height={128} />
|
||||
<Image
|
||||
src="/icon-128.svg"
|
||||
className={cn("dark:invert", className)}
|
||||
alt="logo"
|
||||
width={128}
|
||||
height={128}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils";
|
||||
import { trackLoginSuccess } from "@/lib/posthog/events";
|
||||
|
|
@ -25,7 +25,6 @@ const TokenHandler = ({
|
|||
tokenParamName = "token",
|
||||
storageKey = "surfsense_bearer_token",
|
||||
}: TokenHandlerProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -58,14 +57,14 @@ const TokenHandler = ({
|
|||
const finalRedirectPath = savedRedirectPath || redirectPath;
|
||||
|
||||
// Redirect to the appropriate path
|
||||
router.push(finalRedirectPath);
|
||||
window.location.href = finalRedirectPath;
|
||||
} catch (error) {
|
||||
console.error("Error storing token in localStorage:", error);
|
||||
// Even if there's an error, try to redirect to the default path
|
||||
router.push(redirectPath);
|
||||
window.location.href = redirectPath;
|
||||
}
|
||||
}
|
||||
}, [searchParams, tokenParamName, storageKey, redirectPath, router]);
|
||||
}, [searchParams, tokenParamName, storageKey, redirectPath]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
|
|
|
|||
|
|
@ -34,14 +34,14 @@ export function UserDropdown({
|
|||
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("surfsense_bearer_token");
|
||||
router.push("/");
|
||||
window.location.href = "/";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error);
|
||||
// Optionally, provide user feedback
|
||||
if (typeof window !== "undefined") {
|
||||
alert("Logout failed. Please try again.");
|
||||
router.push("/");
|
||||
window.location.href = "/";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { format, subDays, subYears } from "date-fns";
|
||||
import { addDays, format, subDays, subYears } from "date-fns";
|
||||
import { Calendar as CalendarIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -14,6 +14,7 @@ interface DateRangeSelectorProps {
|
|||
endDate: Date | undefined;
|
||||
onStartDateChange: (date: Date | undefined) => void;
|
||||
onEndDateChange: (date: Date | undefined) => void;
|
||||
allowFutureDates?: boolean; // Allow future dates for calendar connectors
|
||||
}
|
||||
|
||||
export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
||||
|
|
@ -21,6 +22,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
|||
endDate,
|
||||
onStartDateChange,
|
||||
onEndDateChange,
|
||||
allowFutureDates = false,
|
||||
}) => {
|
||||
const handleLast30Days = () => {
|
||||
const today = new Date();
|
||||
|
|
@ -28,6 +30,12 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
|||
onEndDateChange(today);
|
||||
};
|
||||
|
||||
const handleNext30Days = () => {
|
||||
const today = new Date();
|
||||
onStartDateChange(today);
|
||||
onEndDateChange(addDays(today, 30));
|
||||
};
|
||||
|
||||
const handleLastYear = () => {
|
||||
const today = new Date();
|
||||
onStartDateChange(subYears(today, 1));
|
||||
|
|
@ -43,8 +51,9 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
|||
<div className="rounded-xl bg-slate-400/5 dark:bg-white/5 p-3 sm:p-6">
|
||||
<h3 className="font-medium text-sm sm:text-base mb-4">Select Date Range</h3>
|
||||
<p className="text-xs sm:text-sm text-muted-foreground mb-6">
|
||||
Choose how far back you want to sync your data. You can always re-index later with different
|
||||
dates.
|
||||
{allowFutureDates
|
||||
? "Choose the date range to sync your data. You can select future dates to index upcoming events."
|
||||
: "Choose how far back you want to sync your data. You can always re-index later with different dates."}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
|
|
@ -72,7 +81,7 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
|||
mode="single"
|
||||
selected={startDate}
|
||||
onSelect={onStartDateChange}
|
||||
disabled={(date) => date > new Date()}
|
||||
disabled={allowFutureDates ? false : (date) => date > new Date()}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
@ -102,7 +111,11 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
|||
mode="single"
|
||||
selected={endDate}
|
||||
onSelect={onEndDateChange}
|
||||
disabled={(date) => date > new Date() || (startDate ? date < startDate : false)}
|
||||
disabled={
|
||||
allowFutureDates
|
||||
? (date) => (startDate ? date < startDate : false)
|
||||
: (date) => date > new Date() || (startDate ? date < startDate : false)
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
@ -129,6 +142,17 @@ export const DateRangeSelector: FC<DateRangeSelectorProps> = ({
|
|||
>
|
||||
Last 30 Days
|
||||
</Button>
|
||||
{allowFutureDates && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNext30Days}
|
||||
className="text-xs sm:text-sm bg-slate-400/5 dark:bg-slate-400/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-slate-400/10"
|
||||
>
|
||||
Next 30 Days
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -173,6 +173,7 @@ export const LumaConnectForm: FC<ConnectFormProps> = ({ onSubmit, isSubmitting }
|
|||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
allowFutureDates={true}
|
||||
/>
|
||||
|
||||
{/* Periodic Sync Config */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { ConnectorConfigProps } from "../index";
|
||||
|
||||
export interface TeamsConfigProps extends ConnectorConfigProps {
|
||||
onNameChange?: (name: string) => void;
|
||||
}
|
||||
|
||||
export const TeamsConfig: FC<TeamsConfigProps> = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-xl border border-border bg-primary/5 p-4 flex items-start gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/10 shrink-0 mt-0.5">
|
||||
<Info className="size-4" />
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm">
|
||||
<p className="font-medium text-xs sm:text-sm">Microsoft Teams Access</p>
|
||||
<p className="text-muted-foreground mt-1 text-[10px] sm:text-sm">
|
||||
SurfSense will index messages from Teams channels that you have access to. The app can
|
||||
only read messages from teams and channels where you are a member. Make sure you're a
|
||||
member of the teams you want to index before connecting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -17,6 +17,7 @@ import { LumaConfig } from "./components/luma-config";
|
|||
import { SearxngConfig } from "./components/searxng-config";
|
||||
import { SlackConfig } from "./components/slack-config";
|
||||
import { TavilyApiConfig } from "./components/tavily-api-config";
|
||||
import { TeamsConfig } from "./components/teams-config";
|
||||
import { WebcrawlerConfig } from "./components/webcrawler-config";
|
||||
|
||||
export interface ConnectorConfigProps {
|
||||
|
|
@ -52,6 +53,8 @@ export function getConnectorConfigComponent(
|
|||
return SlackConfig;
|
||||
case "DISCORD_CONNECTOR":
|
||||
return DiscordConfig;
|
||||
case "TEAMS_CONNECTOR":
|
||||
return TeamsConfig;
|
||||
case "CONFLUENCE_CONNECTOR":
|
||||
return ConfluenceConfig;
|
||||
case "BOOKSTACK_CONNECTOR":
|
||||
|
|
|
|||
|
|
@ -211,6 +211,10 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
endDate={endDate}
|
||||
onStartDateChange={onStartDateChange}
|
||||
onEndDateChange={onEndDateChange}
|
||||
allowFutureDates={
|
||||
connector.connector_type === "GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
connector.connector_type === "LUMA_CONNECTOR"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -159,6 +159,10 @@ export const IndexingConfigurationView: FC<IndexingConfigurationViewProps> = ({
|
|||
endDate={endDate}
|
||||
onStartDateChange={onStartDateChange}
|
||||
onEndDateChange={onEndDateChange}
|
||||
allowFutureDates={
|
||||
config.connectorType === "GOOGLE_CALENDAR_CONNECTOR" ||
|
||||
config.connectorType === "LUMA_CONNECTOR"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,13 @@ export const OAUTH_CONNECTORS = [
|
|||
connectorType: EnumConnectorName.SLACK_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/slack/connector/add/",
|
||||
},
|
||||
{
|
||||
id: "teams-connector",
|
||||
title: "Microsoft Teams",
|
||||
description: "Search Teams messages",
|
||||
connectorType: EnumConnectorName.TEAMS_CONNECTOR,
|
||||
authEndpoint: "/api/v1/auth/teams/connector/add/",
|
||||
},
|
||||
{
|
||||
id: "discord-connector",
|
||||
title: "Discord",
|
||||
|
|
|
|||
|
|
@ -80,6 +80,12 @@ export const useConnectorDialog = () => {
|
|||
connectorTitle: string;
|
||||
} | null>(null);
|
||||
|
||||
// Track if we came from accounts list when entering edit mode
|
||||
const [cameFromAccountsList, setCameFromAccountsList] = useState<{
|
||||
connectorType: string;
|
||||
connectorTitle: string;
|
||||
} | null>(null);
|
||||
|
||||
// Helper function to get frequency label
|
||||
const getFrequencyLabel = useCallback((minutes: string): string => {
|
||||
switch (minutes) {
|
||||
|
|
@ -960,6 +966,14 @@ export const useConnectorDialog = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Track if we came from accounts list view
|
||||
// If viewingAccountsType matches this connector type, preserve it
|
||||
if (viewingAccountsType && viewingAccountsType.connectorType === connector.connector_type) {
|
||||
setCameFromAccountsList(viewingAccountsType);
|
||||
} else {
|
||||
setCameFromAccountsList(null);
|
||||
}
|
||||
|
||||
// Track index with date range opened event
|
||||
if (connector.is_indexable) {
|
||||
trackIndexWithDateRangeOpened(
|
||||
|
|
@ -989,7 +1003,7 @@ export const useConnectorDialog = () => {
|
|||
url.searchParams.set("connectorId", connector.id.toString());
|
||||
window.history.pushState({ modal: true }, "", url.toString());
|
||||
},
|
||||
[searchSpaceId]
|
||||
[searchSpaceId, viewingAccountsType]
|
||||
);
|
||||
|
||||
// Handle saving connector changes
|
||||
|
|
@ -1252,13 +1266,30 @@ export const useConnectorDialog = () => {
|
|||
|
||||
// Handle going back from edit view
|
||||
const handleBackFromEdit = useCallback(() => {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("tab", "all");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorId");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}, [router]);
|
||||
// If we came from accounts list view, go back there
|
||||
if (cameFromAccountsList && editingConnector) {
|
||||
// Restore accounts list view
|
||||
setViewingAccountsType(cameFromAccountsList);
|
||||
setCameFromAccountsList(null);
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("view", "accounts");
|
||||
url.searchParams.set("connectorType", cameFromAccountsList.connectorType);
|
||||
url.searchParams.delete("connectorId");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
} else {
|
||||
// Otherwise, go back to main connector popup
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("modal", "connectors");
|
||||
url.searchParams.set("tab", "all");
|
||||
url.searchParams.delete("view");
|
||||
url.searchParams.delete("connectorId");
|
||||
router.replace(url.pathname + url.search, { scroll: false });
|
||||
}
|
||||
setEditingConnector(null);
|
||||
setConnectorName(null);
|
||||
setConnectorConfig(null);
|
||||
}, [router, cameFromAccountsList, editingConnector]);
|
||||
|
||||
// Handle dialog open/close
|
||||
const handleOpenChange = useCallback(
|
||||
|
|
@ -1289,6 +1320,7 @@ export const useConnectorDialog = () => {
|
|||
setConnectorConfig(null);
|
||||
setConnectingConnectorType(null);
|
||||
setViewingAccountsType(null);
|
||||
setCameFromAccountsList(null);
|
||||
setStartDate(undefined);
|
||||
setEndDate(undefined);
|
||||
setPeriodicEnabled(false);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
export const CONNECTOR_TO_DOCUMENT_TYPE: Record<string, string> = {
|
||||
// Direct mappings (connector type matches document type)
|
||||
SLACK_CONNECTOR: "SLACK_CONNECTOR",
|
||||
TEAMS_CONNECTOR: "TEAMS_CONNECTOR",
|
||||
NOTION_CONNECTOR: "NOTION_CONNECTOR",
|
||||
GITHUB_CONNECTOR: "GITHUB_CONNECTOR",
|
||||
LINEAR_CONNECTOR: "LINEAR_CONNECTOR",
|
||||
|
|
|
|||
|
|
@ -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,54 +66,65 @@ 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-4 sm:px-12 pt-6 sm:pt-10 pb-4 border-b border-border/50 bg-muted">
|
||||
<div className="flex items-center justify-between gap-4 sm:pr-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 rounded-full shrink-0"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-slate-400/5 dark:bg-white/5 border border-slate-400/5 dark:border-white/5">
|
||||
{getConnectorIcon(connectorType, "size-5")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{connectorTitle} Accounts</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{typeConnectors.length} connected account{typeConnectors.length !== 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<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"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-xs sm:text-sm text-muted-foreground hover:text-foreground mb-6 w-fit"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
Back to connectors
|
||||
</button>
|
||||
|
||||
{/* Connector header */}
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 mb-6">
|
||||
<div className="flex gap-4 flex-1 w-full sm:w-auto">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 border border-primary/20 shrink-0">
|
||||
{getConnectorIcon(connectorType, "size-7")}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl sm:text-2xl font-semibold tracking-tight text-wrap whitespace-normal">
|
||||
{connectorTitle}
|
||||
</h2>
|
||||
<p className="text-xs sm:text-base text-muted-foreground mt-1">
|
||||
{statusMessage || "Manage your connector settings and sync configuration"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* Add Account Button with dashed border */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddAccount}
|
||||
disabled={isConnecting}
|
||||
disabled={isConnecting || !isEnabled}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg mr-4 border-2 border-dashed border-border/70 text-left transition-all duration-200",
|
||||
"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>
|
||||
|
|
@ -120,7 +132,7 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-4 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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ const DesktopNav = ({ navItems, isScrolled }: any) => {
|
|||
: "bg-transparent border border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-1 flex-row items-center gap-2">
|
||||
<div className="flex flex-1 flex-row items-center gap-0.5">
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
||||
</div>
|
||||
|
|
|
|||
1
surfsense_web/components/layout/hooks/index.ts
Normal file
1
surfsense_web/components/layout/hooks/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { useSidebarState } from "./useSidebarState";
|
||||
61
surfsense_web/components/layout/hooks/useSidebarState.ts
Normal file
61
surfsense_web/components/layout/hooks/useSidebarState.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_collapsed";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year
|
||||
|
||||
interface UseSidebarStateReturn {
|
||||
isCollapsed: boolean;
|
||||
setIsCollapsed: (collapsed: boolean) => void;
|
||||
toggleCollapsed: () => void;
|
||||
}
|
||||
|
||||
export function useSidebarState(defaultCollapsed = false): UseSidebarStateReturn {
|
||||
const [isCollapsed, setIsCollapsedState] = useState(defaultCollapsed);
|
||||
|
||||
// Initialize from cookie on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const match = document.cookie.match(/(?:^|; )sidebar_collapsed=([^;]+)/);
|
||||
if (match) {
|
||||
setIsCollapsedState(match[1] === "true");
|
||||
}
|
||||
} catch {
|
||||
// Ignore cookie read errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Persist to cookie when state changes
|
||||
const setIsCollapsed = useCallback((collapsed: boolean) => {
|
||||
setIsCollapsedState(collapsed);
|
||||
try {
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${collapsed}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
} catch {
|
||||
// Ignore cookie write errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleCollapsed = useCallback(() => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
}, [isCollapsed, setIsCollapsed]);
|
||||
|
||||
// Keyboard shortcut: Cmd/Ctrl + B
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "b" && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
toggleCollapsed();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleCollapsed]);
|
||||
|
||||
return {
|
||||
isCollapsed,
|
||||
setIsCollapsed,
|
||||
toggleCollapsed,
|
||||
};
|
||||
}
|
||||
32
surfsense_web/components/layout/index.ts
Normal file
32
surfsense_web/components/layout/index.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
export { useSidebarState } from "./hooks";
|
||||
export { LayoutDataProvider } from "./providers";
|
||||
export type {
|
||||
ChatItem,
|
||||
IconRailProps,
|
||||
NavItem,
|
||||
NoteItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
SidebarSectionProps,
|
||||
User,
|
||||
} from "./types/layout.types";
|
||||
export {
|
||||
AllSearchSpacesSheet,
|
||||
ChatListItem,
|
||||
CreateSearchSpaceDialog,
|
||||
Header,
|
||||
IconRail,
|
||||
LayoutShell,
|
||||
MobileSidebar,
|
||||
MobileSidebarTrigger,
|
||||
NavIcon,
|
||||
NavSection,
|
||||
NoteListItem,
|
||||
PageUsageDisplay,
|
||||
SearchSpaceAvatar,
|
||||
Sidebar,
|
||||
SidebarCollapseButton,
|
||||
SidebarHeader,
|
||||
SidebarSection,
|
||||
SidebarUserProfile,
|
||||
} from "./ui";
|
||||
537
surfsense_web/components/layout/providers/LayoutDataProvider.tsx
Normal file
537
surfsense_web/components/layout/providers/LayoutDataProvider.tsx
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { Logs, SquareLibrary, Trash2 } from "lucide-react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
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";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useLogsSummary } from "@/hooks/use-logs";
|
||||
import { notesApiService } from "@/lib/apis/notes-api.service";
|
||||
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, 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";
|
||||
|
||||
interface LayoutDataProviderProps {
|
||||
searchSpaceId: string;
|
||||
children: React.ReactNode;
|
||||
breadcrumb?: React.ReactNode;
|
||||
languageSwitcher?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LayoutDataProvider({
|
||||
searchSpaceId,
|
||||
children,
|
||||
breadcrumb,
|
||||
languageSwitcher,
|
||||
}: LayoutDataProviderProps) {
|
||||
const t = useTranslations("dashboard");
|
||||
const tCommon = useTranslations("common");
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
// Atoms
|
||||
const { data: user } = useAtomValue(currentUserAtom);
|
||||
const { data: searchSpacesData, refetch: refetchSearchSpaces } = useAtomValue(searchSpacesAtom);
|
||||
const { mutateAsync: deleteSearchSpace } = useAtomValue(deleteSearchSpaceMutationAtom);
|
||||
const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
|
||||
const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
|
||||
|
||||
// Current IDs from URL
|
||||
const currentChatId = params?.chat_id
|
||||
? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id)
|
||||
: null;
|
||||
const currentNoteId = params?.note_id
|
||||
? Number(Array.isArray(params.note_id) ? params.note_id[0] : params.note_id)
|
||||
: null;
|
||||
|
||||
// Fetch current search space
|
||||
const { data: searchSpace } = useQuery({
|
||||
queryKey: cacheKeys.searchSpaces.detail(searchSpaceId),
|
||||
queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }),
|
||||
enabled: !!searchSpaceId,
|
||||
});
|
||||
|
||||
// Fetch threads
|
||||
const { data: threadsData, refetch: refetchThreads } = useQuery({
|
||||
queryKey: ["threads", searchSpaceId, { limit: 4 }],
|
||||
queryFn: () => fetchThreads(Number(searchSpaceId), 4),
|
||||
enabled: !!searchSpaceId,
|
||||
});
|
||||
|
||||
// Fetch notes
|
||||
const { data: notesData, refetch: refetchNotes } = useQuery({
|
||||
queryKey: ["notes", searchSpaceId],
|
||||
queryFn: () =>
|
||||
notesApiService.getNotes({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
page_size: 4,
|
||||
}),
|
||||
enabled: !!searchSpaceId,
|
||||
});
|
||||
|
||||
// Poll for active reindexing tasks to show inline loading indicators
|
||||
const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, {
|
||||
enablePolling: true,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
// Create a Set of document IDs that are currently being reindexed
|
||||
const reindexingDocumentIds = useMemo(() => {
|
||||
if (!summary?.active_tasks) return new Set<number>();
|
||||
return new Set(
|
||||
summary.active_tasks
|
||||
.filter((task) => task.document_id != null)
|
||||
.map((task) => task.document_id as number)
|
||||
);
|
||||
}, [summary?.active_tasks]);
|
||||
|
||||
// All chats/notes sidebars state
|
||||
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);
|
||||
const [isDeletingChat, setIsDeletingChat] = useState(false);
|
||||
|
||||
const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false);
|
||||
const [noteToDelete, setNoteToDelete] = useState<{
|
||||
id: number;
|
||||
name: string;
|
||||
search_space_id: number;
|
||||
} | null>(null);
|
||||
const [isDeletingNote, setIsDeletingNote] = useState(false);
|
||||
|
||||
const searchSpaces: SearchSpace[] = useMemo(() => {
|
||||
if (!searchSpacesData || !Array.isArray(searchSpacesData)) return [];
|
||||
return searchSpacesData.map((space) => ({
|
||||
id: space.id,
|
||||
name: space.name,
|
||||
description: space.description,
|
||||
isOwner: space.is_owner,
|
||||
memberCount: space.member_count || 0,
|
||||
createdAt: space.created_at,
|
||||
}));
|
||||
}, [searchSpacesData]);
|
||||
|
||||
// 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(() => {
|
||||
if (!threadsData?.threads) return [];
|
||||
return threadsData.threads.map((thread) => ({
|
||||
id: thread.id,
|
||||
name: thread.title || `Chat ${thread.id}`,
|
||||
url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`,
|
||||
}));
|
||||
}, [threadsData, searchSpaceId]);
|
||||
|
||||
// Transform notes
|
||||
const notes: NoteItem[] = useMemo(() => {
|
||||
if (!notesData?.items) return [];
|
||||
const sortedNotes = [...notesData.items].sort((a, b) => {
|
||||
const dateA = a.updated_at
|
||||
? new Date(a.updated_at).getTime()
|
||||
: new Date(a.created_at).getTime();
|
||||
const dateB = b.updated_at
|
||||
? new Date(b.updated_at).getTime()
|
||||
: new Date(b.created_at).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
return sortedNotes.slice(0, 4).map((note) => ({
|
||||
id: note.id,
|
||||
name: note.title,
|
||||
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
|
||||
isReindexing: reindexingDocumentIds.has(note.id),
|
||||
}));
|
||||
}, [notesData, reindexingDocumentIds]);
|
||||
|
||||
// Navigation items
|
||||
const navItems: NavItem[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: "Documents",
|
||||
url: `/dashboard/${searchSpaceId}/documents`,
|
||||
icon: SquareLibrary,
|
||||
isActive: pathname?.includes("/documents"),
|
||||
},
|
||||
{
|
||||
title: "Logs",
|
||||
url: `/dashboard/${searchSpaceId}/logs`,
|
||||
icon: Logs,
|
||||
isActive: pathname?.includes("/logs"),
|
||||
},
|
||||
],
|
||||
[searchSpaceId, pathname]
|
||||
);
|
||||
|
||||
// Handlers
|
||||
const handleSearchSpaceSelect = useCallback(
|
||||
(id: number) => {
|
||||
router.push(`/dashboard/${id}/new-chat`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleAddSearchSpace = useCallback(() => {
|
||||
setIsCreateSearchSpaceDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSeeAllSearchSpaces = useCallback(() => {
|
||||
setIsAllSearchSpacesSheetOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleUserSettings = useCallback(() => {
|
||||
router.push("/dashboard/user/settings");
|
||||
}, [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) => {
|
||||
router.push(item.url);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
const handleChatSelect = useCallback(
|
||||
(chat: ChatItem) => {
|
||||
router.push(chat.url);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleChatDelete = useCallback((chat: ChatItem) => {
|
||||
setChatToDelete({ id: chat.id, name: chat.name });
|
||||
setShowDeleteChatDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleNoteSelect = useCallback(
|
||||
(note: NoteItem) => {
|
||||
if (hasUnsavedEditorChanges) {
|
||||
setPendingNavigation(note.url);
|
||||
} else {
|
||||
router.push(note.url);
|
||||
}
|
||||
},
|
||||
[router, hasUnsavedEditorChanges, setPendingNavigation]
|
||||
);
|
||||
|
||||
const handleNoteDelete = useCallback(
|
||||
(note: NoteItem) => {
|
||||
setNoteToDelete({ id: note.id, name: note.name, search_space_id: Number(searchSpaceId) });
|
||||
setShowDeleteNoteDialog(true);
|
||||
},
|
||||
[searchSpaceId]
|
||||
);
|
||||
|
||||
const handleAddNote = useCallback(() => {
|
||||
const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`;
|
||||
if (hasUnsavedEditorChanges) {
|
||||
setPendingNavigation(newNoteUrl);
|
||||
} else {
|
||||
router.push(newNoteUrl);
|
||||
}
|
||||
}, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]);
|
||||
|
||||
const handleSettings = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/settings`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
const handleManageMembers = useCallback(() => {
|
||||
router.push(`/dashboard/${searchSpaceId}/team`);
|
||||
}, [router, searchSpaceId]);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
try {
|
||||
trackLogout();
|
||||
resetUser();
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("surfsense_bearer_token");
|
||||
router.push("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error);
|
||||
router.push("/");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const handleToggleTheme = useCallback(() => {
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
}, [theme, setTheme]);
|
||||
|
||||
const handleViewAllChats = useCallback(() => {
|
||||
setIsAllChatsSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleViewAllNotes = useCallback(() => {
|
||||
setIsAllNotesSidebarOpen(true);
|
||||
}, []);
|
||||
|
||||
// Delete handlers
|
||||
const confirmDeleteChat = useCallback(async () => {
|
||||
if (!chatToDelete) return;
|
||||
setIsDeletingChat(true);
|
||||
try {
|
||||
await deleteThread(chatToDelete.id);
|
||||
queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] });
|
||||
if (currentChatId === chatToDelete.id) {
|
||||
router.push(`/dashboard/${searchSpaceId}/new-chat`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting thread:", error);
|
||||
} finally {
|
||||
setIsDeletingChat(false);
|
||||
setShowDeleteChatDialog(false);
|
||||
setChatToDelete(null);
|
||||
}
|
||||
}, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]);
|
||||
|
||||
const confirmDeleteNote = useCallback(async () => {
|
||||
if (!noteToDelete) return;
|
||||
setIsDeletingNote(true);
|
||||
try {
|
||||
await notesApiService.deleteNote({
|
||||
search_space_id: noteToDelete.search_space_id,
|
||||
note_id: noteToDelete.id,
|
||||
});
|
||||
refetchNotes();
|
||||
} catch (error) {
|
||||
console.error("Error deleting note:", error);
|
||||
} finally {
|
||||
setIsDeletingNote(false);
|
||||
setShowDeleteNoteDialog(false);
|
||||
setNoteToDelete(null);
|
||||
}
|
||||
}, [noteToDelete, refetchNotes]);
|
||||
|
||||
// Page usage
|
||||
const pageUsage = user
|
||||
? {
|
||||
pagesUsed: user.pages_used,
|
||||
pagesLimit: user.pages_limit,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Detect if we're on the chat page (needs overflow-hidden for chat's own scroll)
|
||||
const isChatPage = pathname?.includes("/new-chat") ?? false;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LayoutShell
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={Number(searchSpaceId)}
|
||||
onSearchSpaceSelect={handleSearchSpaceSelect}
|
||||
onAddSearchSpace={handleAddSearchSpace}
|
||||
searchSpace={activeSearchSpace}
|
||||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
chats={chats}
|
||||
activeChatId={currentChatId}
|
||||
onNewChat={handleNewChat}
|
||||
onChatSelect={handleChatSelect}
|
||||
onChatDelete={handleChatDelete}
|
||||
onViewAllChats={handleViewAllChats}
|
||||
notes={notes}
|
||||
activeNoteId={currentNoteId}
|
||||
onNoteSelect={handleNoteSelect}
|
||||
onNoteDelete={handleNoteDelete}
|
||||
onAddNote={handleAddNote}
|
||||
onViewAllNotes={handleViewAllNotes}
|
||||
user={{ email: user?.email || "", name: user?.email?.split("@")[0] }}
|
||||
onSettings={handleSettings}
|
||||
onManageMembers={handleManageMembers}
|
||||
onSeeAllSearchSpaces={handleSeeAllSearchSpaces}
|
||||
onUserSettings={handleUserSettings}
|
||||
onLogout={handleLogout}
|
||||
pageUsage={pageUsage}
|
||||
breadcrumb={breadcrumb}
|
||||
languageSwitcher={languageSwitcher}
|
||||
theme={theme}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
isChatPage={isChatPage}
|
||||
>
|
||||
{children}
|
||||
</LayoutShell>
|
||||
|
||||
{/* Delete Chat Dialog */}
|
||||
<Dialog open={showDeleteChatDialog} onOpenChange={setShowDeleteChatDialog}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
<span>{t("delete_chat")}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("delete_chat_confirm")} <span className="font-medium">{chatToDelete?.name}</span>?{" "}
|
||||
{t("action_cannot_undone")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteChatDialog(false)}
|
||||
disabled={isDeletingChat}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDeleteChat}
|
||||
disabled={isDeletingChat}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDeletingChat ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{t("deleting")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{tCommon("delete")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* All Chats Sidebar */}
|
||||
<AllChatsSidebar
|
||||
open={isAllChatsSidebarOpen}
|
||||
onOpenChange={setIsAllChatsSidebarOpen}
|
||||
searchSpaceId={searchSpaceId}
|
||||
/>
|
||||
|
||||
{/* All Notes Sidebar */}
|
||||
<AllNotesSidebar
|
||||
open={isAllNotesSidebarOpen}
|
||||
onOpenChange={setIsAllNotesSidebarOpen}
|
||||
searchSpaceId={searchSpaceId}
|
||||
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">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash2 className="h-5 w-5 text-destructive" />
|
||||
<span>{t("delete_note")}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("delete_note_confirm")} <span className="font-medium">{noteToDelete?.name}</span>?{" "}
|
||||
{t("action_cannot_undone")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteNoteDialog(false)}
|
||||
disabled={isDeletingNote}
|
||||
>
|
||||
{tCommon("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDeleteNote}
|
||||
disabled={isDeletingNote}
|
||||
className="gap-2"
|
||||
>
|
||||
{isDeletingNote ? (
|
||||
<>
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
{t("deleting")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{tCommon("delete")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
surfsense_web/components/layout/providers/index.ts
Normal file
1
surfsense_web/components/layout/providers/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { LayoutDataProvider } from "./LayoutDataProvider";
|
||||
140
surfsense_web/components/layout/types/layout.types.ts
Normal file
140
surfsense_web/components/layout/types/layout.types.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export interface SearchSpace {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
isOwner: boolean;
|
||||
memberCount: number;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: LucideIcon;
|
||||
isActive?: boolean;
|
||||
badge?: string | number;
|
||||
}
|
||||
|
||||
export interface ChatItem {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface NoteItem {
|
||||
id: number;
|
||||
name: string;
|
||||
url: string;
|
||||
isActive?: boolean;
|
||||
isReindexing?: boolean;
|
||||
}
|
||||
|
||||
export interface PageUsage {
|
||||
pagesUsed: number;
|
||||
pagesLimit: number;
|
||||
}
|
||||
|
||||
export interface IconRailProps {
|
||||
searchSpaces: SearchSpace[];
|
||||
activeSearchSpaceId: number | null;
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onAddSearchSpace: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface SidebarHeaderProps {
|
||||
searchSpace: SearchSpace | null;
|
||||
onSettings?: () => void;
|
||||
}
|
||||
|
||||
export interface SidebarSectionProps {
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface NavSectionProps {
|
||||
items: NavItem[];
|
||||
onItemClick?: (item: NavItem) => void;
|
||||
}
|
||||
|
||||
export interface ChatsSectionProps {
|
||||
chats: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllChats?: () => void;
|
||||
searchSpaceId?: string;
|
||||
}
|
||||
|
||||
export interface NotesSectionProps {
|
||||
notes: NoteItem[];
|
||||
activeNoteId?: number | null;
|
||||
onNoteSelect: (note: NoteItem) => void;
|
||||
onNoteDelete?: (note: NoteItem) => void;
|
||||
onAddNote?: () => void;
|
||||
onViewAllNotes?: () => void;
|
||||
searchSpaceId?: string;
|
||||
}
|
||||
|
||||
export interface PageUsageDisplayProps {
|
||||
pagesUsed: number;
|
||||
pagesLimit: number;
|
||||
}
|
||||
|
||||
export interface SidebarUserProfileProps {
|
||||
user: User;
|
||||
searchSpaceId?: string;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
onSwitchSearchSpace?: () => void;
|
||||
onToggleTheme?: () => void;
|
||||
onLogout?: () => void;
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
export interface SidebarProps {
|
||||
searchSpace: SearchSpace | null;
|
||||
searchSpaceId?: string;
|
||||
navItems: NavItem[];
|
||||
chats: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllChats?: () => void;
|
||||
notes: NoteItem[];
|
||||
activeNoteId?: number | null;
|
||||
onNoteSelect: (note: NoteItem) => void;
|
||||
onNoteDelete?: (note: NoteItem) => void;
|
||||
onAddNote?: () => void;
|
||||
onViewAllNotes?: () => void;
|
||||
user: User;
|
||||
theme?: string;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
onSeeAllSearchSpaces?: () => void;
|
||||
onToggleTheme?: () => void;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface LayoutShellProps {
|
||||
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";
|
||||
49
surfsense_web/components/layout/ui/header/Header.tsx
Normal file
49
surfsense_web/components/layout/ui/header/Header.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client";
|
||||
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface HeaderProps {
|
||||
breadcrumb?: React.ReactNode;
|
||||
languageSwitcher?: React.ReactNode;
|
||||
theme?: string;
|
||||
onToggleTheme?: () => void;
|
||||
mobileMenuTrigger?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Header({
|
||||
breadcrumb,
|
||||
languageSwitcher,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
mobileMenuTrigger,
|
||||
}: HeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 backdrop-blur supports-backdrop-filter:bg-background/60 px-4">
|
||||
{/* Left side - Mobile menu trigger + Breadcrumb */}
|
||||
<div className="flex flex-1 items-center gap-2 min-w-0">
|
||||
{mobileMenuTrigger}
|
||||
<div className="hidden md:block">{breadcrumb}</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Theme toggle */}
|
||||
{onToggleTheme && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" onClick={onToggleTheme} className="h-8 w-8">
|
||||
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{theme === "dark" ? "Light mode" : "Dark mode"}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{languageSwitcher}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
1
surfsense_web/components/layout/ui/header/index.ts
Normal file
1
surfsense_web/components/layout/ui/header/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Header } from "./Header";
|
||||
60
surfsense_web/components/layout/ui/icon-rail/IconRail.tsx
Normal file
60
surfsense_web/components/layout/ui/icon-rail/IconRail.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
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 { SearchSpace } from "../../types/layout.types";
|
||||
import { SearchSpaceAvatar } from "./SearchSpaceAvatar";
|
||||
|
||||
interface IconRailProps {
|
||||
searchSpaces: SearchSpace[];
|
||||
activeSearchSpaceId: number | null;
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onAddSearchSpace: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function IconRail({
|
||||
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">
|
||||
{searchSpaces.map((searchSpace) => (
|
||||
<SearchSpaceAvatar
|
||||
key={searchSpace.id}
|
||||
name={searchSpace.name}
|
||||
isActive={searchSpace.id === activeSearchSpaceId}
|
||||
onClick={() => onSearchSpaceSelect(searchSpace.id)}
|
||||
size="md"
|
||||
/>
|
||||
))}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
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 search space</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
Add search space
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
surfsense_web/components/layout/ui/icon-rail/NavIcon.tsx
Normal file
34
surfsense_web/components/layout/ui/icon-rail/NavIcon.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NavIconProps {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function NavIcon({ icon: Icon, label, isActive, onClick }: NavIconProps) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClick}
|
||||
className={cn("h-10 w-10 rounded-lg", isActive && "bg-accent text-accent-foreground")}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
<span className="sr-only">{label}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SearchSpaceAvatarProps {
|
||||
name: string;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a consistent color based on search space name
|
||||
*/
|
||||
function stringToColor(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const colors = [
|
||||
"#6366f1", // indigo
|
||||
"#22c55e", // green
|
||||
"#f59e0b", // amber
|
||||
"#ef4444", // red
|
||||
"#8b5cf6", // violet
|
||||
"#06b6d4", // cyan
|
||||
"#ec4899", // pink
|
||||
"#14b8a6", // teal
|
||||
];
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets initials from search space name (max 2 chars)
|
||||
*/
|
||||
function getInitials(name: string): string {
|
||||
const words = name.trim().split(/\s+/);
|
||||
if (words.length >= 2) {
|
||||
return (words[0][0] + words[1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex items-center justify-center rounded-lg font-semibold text-white transition-all",
|
||||
"hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
sizeClasses,
|
||||
isActive && "ring-2 ring-primary ring-offset-1 ring-offset-background"
|
||||
)}
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{initials}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
3
surfsense_web/components/layout/ui/icon-rail/index.ts
Normal file
3
surfsense_web/components/layout/ui/icon-rail/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { IconRail } from "./IconRail";
|
||||
export { NavIcon } from "./NavIcon";
|
||||
export { SearchSpaceAvatar } from "./SearchSpaceAvatar";
|
||||
18
surfsense_web/components/layout/ui/index.ts
Normal file
18
surfsense_web/components/layout/ui/index.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export { CreateSearchSpaceDialog } from "./dialogs";
|
||||
export { Header } from "./header";
|
||||
export { IconRail, NavIcon, SearchSpaceAvatar } from "./icon-rail";
|
||||
export { AllSearchSpacesSheet } from "./sheets";
|
||||
export { LayoutShell } from "./shell";
|
||||
export {
|
||||
ChatListItem,
|
||||
MobileSidebar,
|
||||
MobileSidebarTrigger,
|
||||
NavSection,
|
||||
NoteListItem,
|
||||
PageUsageDisplay,
|
||||
Sidebar,
|
||||
SidebarCollapseButton,
|
||||
SidebarHeader,
|
||||
SidebarSection,
|
||||
SidebarUserProfile,
|
||||
} from "./sidebar";
|
||||
|
|
@ -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";
|
||||
207
surfsense_web/components/layout/ui/shell/LayoutShell.tsx
Normal file
207
surfsense_web/components/layout/ui/shell/LayoutShell.tsx
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSidebarState } from "../../hooks";
|
||||
import type {
|
||||
ChatItem,
|
||||
NavItem,
|
||||
NoteItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
User,
|
||||
} from "../../types/layout.types";
|
||||
import { Header } from "../header";
|
||||
import { IconRail } from "../icon-rail";
|
||||
import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar";
|
||||
|
||||
interface LayoutShellProps {
|
||||
searchSpaces: SearchSpace[];
|
||||
activeSearchSpaceId: number | null;
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onAddSearchSpace: () => void;
|
||||
searchSpace: SearchSpace | null;
|
||||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllChats?: () => void;
|
||||
notes: NoteItem[];
|
||||
activeNoteId?: number | null;
|
||||
onNoteSelect: (note: NoteItem) => void;
|
||||
onNoteDelete?: (note: NoteItem) => void;
|
||||
onAddNote?: () => void;
|
||||
onViewAllNotes?: () => void;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
onSeeAllSearchSpaces?: () => void;
|
||||
onUserSettings?: () => void;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
breadcrumb?: React.ReactNode;
|
||||
languageSwitcher?: React.ReactNode;
|
||||
theme?: string;
|
||||
onToggleTheme?: () => void;
|
||||
defaultCollapsed?: boolean;
|
||||
isChatPage?: boolean;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LayoutShell({
|
||||
searchSpaces,
|
||||
activeSearchSpaceId,
|
||||
onSearchSpaceSelect,
|
||||
onAddSearchSpace,
|
||||
searchSpace,
|
||||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
activeChatId,
|
||||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatDelete,
|
||||
onViewAllChats,
|
||||
notes,
|
||||
activeNoteId,
|
||||
onNoteSelect,
|
||||
onNoteDelete,
|
||||
onAddNote,
|
||||
onViewAllNotes,
|
||||
user,
|
||||
onSettings,
|
||||
onManageMembers,
|
||||
onSeeAllSearchSpaces,
|
||||
onUserSettings,
|
||||
onLogout,
|
||||
pageUsage,
|
||||
breadcrumb,
|
||||
languageSwitcher,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
defaultCollapsed = false,
|
||||
isChatPage = false,
|
||||
children,
|
||||
className,
|
||||
}: LayoutShellProps) {
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const { isCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed);
|
||||
|
||||
// Mobile layout
|
||||
if (isMobile) {
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div className={cn("flex h-screen w-full flex-col bg-background", className)}>
|
||||
<Header
|
||||
breadcrumb={breadcrumb}
|
||||
languageSwitcher={languageSwitcher}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
mobileMenuTrigger={<MobileSidebarTrigger onClick={() => setMobileMenuOpen(true)} />}
|
||||
/>
|
||||
|
||||
<MobileSidebar
|
||||
isOpen={mobileMenuOpen}
|
||||
onOpenChange={setMobileMenuOpen}
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={onSearchSpaceSelect}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
searchSpace={searchSpace}
|
||||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
chats={chats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={onNewChat}
|
||||
onChatSelect={onChatSelect}
|
||||
onChatDelete={onChatDelete}
|
||||
onViewAllChats={onViewAllChats}
|
||||
notes={notes}
|
||||
activeNoteId={activeNoteId}
|
||||
onNoteSelect={onNoteSelect}
|
||||
onNoteDelete={onNoteDelete}
|
||||
onAddNote={onAddNote}
|
||||
onViewAllNotes={onViewAllNotes}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
|
||||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
/>
|
||||
|
||||
<main className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop layout
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<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
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={onSearchSpaceSelect}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 rounded-xl border bg-background overflow-hidden">
|
||||
<Sidebar
|
||||
searchSpace={searchSpace}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggleCollapse={toggleCollapsed}
|
||||
navItems={navItems}
|
||||
onNavItemClick={onNavItemClick}
|
||||
chats={chats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={onNewChat}
|
||||
onChatSelect={onChatSelect}
|
||||
onChatDelete={onChatDelete}
|
||||
onViewAllChats={onViewAllChats}
|
||||
notes={notes}
|
||||
activeNoteId={activeNoteId}
|
||||
onNoteSelect={onNoteSelect}
|
||||
onNoteDelete={onNoteDelete}
|
||||
onAddNote={onAddNote}
|
||||
onViewAllNotes={onViewAllNotes}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
|
||||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
className="hidden md:flex border-r shrink-0"
|
||||
/>
|
||||
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<Header
|
||||
breadcrumb={breadcrumb}
|
||||
languageSwitcher={languageSwitcher}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
/>
|
||||
|
||||
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
1
surfsense_web/components/layout/ui/shell/index.ts
Normal file
1
surfsense_web/components/layout/ui/shell/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { LayoutShell } from "./LayoutShell";
|
||||
65
surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
Normal file
65
surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import { MessageSquare, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ChatListItemProps {
|
||||
name: string;
|
||||
isActive?: boolean;
|
||||
onClick?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItemProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
||||
return (
|
||||
<div className="group/item relative w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
||||
"[&>span:last-child]:truncate",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isActive && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="w-[calc(100%-3rem)] ">{name}</span>
|
||||
</button>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("more_options")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="right">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
{t("delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
Normal file
157
surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"use client";
|
||||
|
||||
import { Menu } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import type {
|
||||
ChatItem,
|
||||
NavItem,
|
||||
NoteItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
User,
|
||||
} from "../../types/layout.types";
|
||||
import { IconRail } from "../icon-rail";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
|
||||
interface MobileSidebarProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
searchSpaces: SearchSpace[];
|
||||
activeSearchSpaceId: number | null;
|
||||
onSearchSpaceSelect: (id: number) => void;
|
||||
onAddSearchSpace: () => void;
|
||||
searchSpace: SearchSpace | null;
|
||||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllChats?: () => void;
|
||||
notes: NoteItem[];
|
||||
activeNoteId?: number | null;
|
||||
onNoteSelect: (note: NoteItem) => void;
|
||||
onNoteDelete?: (note: NoteItem) => void;
|
||||
onAddNote?: () => void;
|
||||
onViewAllNotes?: () => void;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
onSeeAllSearchSpaces?: () => void;
|
||||
onUserSettings?: () => void;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
}
|
||||
|
||||
export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<Button variant="ghost" size="icon" className="md:hidden h-8 w-8" onClick={onClick}>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Open menu</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileSidebar({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
searchSpaces,
|
||||
activeSearchSpaceId,
|
||||
onSearchSpaceSelect,
|
||||
onAddSearchSpace,
|
||||
searchSpace,
|
||||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
activeChatId,
|
||||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatDelete,
|
||||
onViewAllChats,
|
||||
notes,
|
||||
activeNoteId,
|
||||
onNoteSelect,
|
||||
onNoteDelete,
|
||||
onAddNote,
|
||||
onViewAllNotes,
|
||||
user,
|
||||
onSettings,
|
||||
onManageMembers,
|
||||
onSeeAllSearchSpaces,
|
||||
onUserSettings,
|
||||
onLogout,
|
||||
pageUsage,
|
||||
}: MobileSidebarProps) {
|
||||
const handleSearchSpaceSelect = (id: number) => {
|
||||
onSearchSpaceSelect(id);
|
||||
};
|
||||
|
||||
const handleNavItemClick = (item: NavItem) => {
|
||||
onNavItemClick?.(item);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleChatSelect = (chat: ChatItem) => {
|
||||
onChatSelect(chat);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleNoteSelect = (note: NoteItem) => {
|
||||
onNoteSelect(note);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="left" className="w-[320px] p-0 flex">
|
||||
<SheetTitle className="sr-only">Navigation</SheetTitle>
|
||||
|
||||
<div className="shrink-0 border-r bg-muted/40">
|
||||
<ScrollArea className="h-full">
|
||||
<IconRail
|
||||
searchSpaces={searchSpaces}
|
||||
activeSearchSpaceId={activeSearchSpaceId}
|
||||
onSearchSpaceSelect={handleSearchSpaceSelect}
|
||||
onAddSearchSpace={onAddSearchSpace}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Sidebar
|
||||
searchSpace={searchSpace}
|
||||
isCollapsed={false}
|
||||
navItems={navItems}
|
||||
onNavItemClick={handleNavItemClick}
|
||||
chats={chats}
|
||||
activeChatId={activeChatId}
|
||||
onNewChat={() => {
|
||||
onNewChat();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
onChatSelect={handleChatSelect}
|
||||
onChatDelete={onChatDelete}
|
||||
onViewAllChats={onViewAllChats}
|
||||
notes={notes}
|
||||
activeNoteId={activeNoteId}
|
||||
onNoteSelect={handleNoteSelect}
|
||||
onNoteDelete={onNoteDelete}
|
||||
onAddNote={onAddNote}
|
||||
onViewAllNotes={onViewAllNotes}
|
||||
user={user}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
|
||||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
pageUsage={pageUsage}
|
||||
className="w-full border-none"
|
||||
/>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
73
surfsense_web/components/layout/ui/sidebar/NavSection.tsx
Normal file
73
surfsense_web/components/layout/ui/sidebar/NavSection.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
"use client";
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { NavItem } from "../../types/layout.types";
|
||||
|
||||
interface NavSectionProps {
|
||||
items: NavItem[];
|
||||
onItemClick?: (item: NavItem) => void;
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-0.5 py-2", isCollapsed && "items-center")}>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
|
||||
// Add data-joyride for onboarding tour
|
||||
const joyrideAttr =
|
||||
item.title === "Documents" || item.title.toLowerCase().includes("documents")
|
||||
? { "data-joyride": "documents-sidebar" }
|
||||
: {};
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<Tooltip key={item.url}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onItemClick?.(item)}
|
||||
className={cn(
|
||||
"flex h-10 w-10 items-center justify-center rounded-md transition-colors",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
item.isActive && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
{...joyrideAttr}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="sr-only">{item.title}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{item.title}
|
||||
{item.badge && ` (${item.badge})`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.url}
|
||||
type="button"
|
||||
onClick={() => onItemClick?.(item)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md mx-2 px-2 py-1.5 text-sm transition-colors text-left",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
item.isActive && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
{...joyrideAttr}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate">{item.title}</span>
|
||||
{item.badge && <span className="text-xs text-muted-foreground">{item.badge}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx
Normal file
76
surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import { FileText, Loader2, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NoteListItemProps {
|
||||
name: string;
|
||||
isActive?: boolean;
|
||||
isReindexing?: boolean;
|
||||
onClick?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export function NoteListItem({
|
||||
name,
|
||||
isActive,
|
||||
isReindexing,
|
||||
onClick,
|
||||
onDelete,
|
||||
}: NoteListItemProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
||||
return (
|
||||
<div className="group/item relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-sm text-left transition-colors",
|
||||
"[&>span:last-child]:truncate",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
|
||||
isActive && "bg-accent text-accent-foreground"
|
||||
)}
|
||||
>
|
||||
{isReindexing ? (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-primary" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span>{name}</span>
|
||||
</button>
|
||||
|
||||
{/* Actions dropdown */}
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/item:opacity-100 transition-opacity">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
<span className="sr-only">{t("more_options")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" side="right">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete?.();
|
||||
}}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
{t("delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { Mail } from "lucide-react";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface PageUsageDisplayProps {
|
||||
pagesUsed: number;
|
||||
pagesLimit: number;
|
||||
}
|
||||
|
||||
export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) {
|
||||
const usagePercentage = (pagesUsed / pagesLimit) * 100;
|
||||
|
||||
return (
|
||||
<div className="px-3 py-3 border-t">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages
|
||||
</span>
|
||||
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
|
||||
</div>
|
||||
<Progress value={usagePercentage} className="h-1.5" />
|
||||
<a
|
||||
href="mailto:rohan@surfsense.com?subject=Request%20to%20Increase%20Page%20Limits"
|
||||
className="flex items-center gap-1.5 text-[10px] text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
<Mail className="h-3 w-3 shrink-0" />
|
||||
<span>Contact to increase limits</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
301
surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
Normal file
301
surfsense_web/components/layout/ui/sidebar/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
"use client";
|
||||
|
||||
import { FileText, FolderOpen, MessageSquare, PenSquare, Plus } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
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 {
|
||||
ChatItem,
|
||||
NavItem,
|
||||
NoteItem,
|
||||
PageUsage,
|
||||
SearchSpace,
|
||||
User,
|
||||
} from "../../types/layout.types";
|
||||
import { ChatListItem } from "./ChatListItem";
|
||||
import { NavSection } from "./NavSection";
|
||||
import { NoteListItem } from "./NoteListItem";
|
||||
import { PageUsageDisplay } from "./PageUsageDisplay";
|
||||
import { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||
import { SidebarHeader } from "./SidebarHeader";
|
||||
import { SidebarSection } from "./SidebarSection";
|
||||
import { SidebarUserProfile } from "./SidebarUserProfile";
|
||||
|
||||
interface SidebarProps {
|
||||
searchSpace: SearchSpace | null;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
navItems: NavItem[];
|
||||
onNavItemClick?: (item: NavItem) => void;
|
||||
chats: ChatItem[];
|
||||
activeChatId?: number | null;
|
||||
onNewChat: () => void;
|
||||
onChatSelect: (chat: ChatItem) => void;
|
||||
onChatDelete?: (chat: ChatItem) => void;
|
||||
onViewAllChats?: () => void;
|
||||
notes: NoteItem[];
|
||||
activeNoteId?: number | null;
|
||||
onNoteSelect: (note: NoteItem) => void;
|
||||
onNoteDelete?: (note: NoteItem) => void;
|
||||
onAddNote?: () => void;
|
||||
onViewAllNotes?: () => void;
|
||||
user: User;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
onSeeAllSearchSpaces?: () => void;
|
||||
onUserSettings?: () => void;
|
||||
onLogout?: () => void;
|
||||
pageUsage?: PageUsage;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
searchSpace,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse,
|
||||
navItems,
|
||||
onNavItemClick,
|
||||
chats,
|
||||
activeChatId,
|
||||
onNewChat,
|
||||
onChatSelect,
|
||||
onChatDelete,
|
||||
onViewAllChats,
|
||||
notes,
|
||||
activeNoteId,
|
||||
onNoteSelect,
|
||||
onNoteDelete,
|
||||
onAddNote,
|
||||
onViewAllNotes,
|
||||
user,
|
||||
onSettings,
|
||||
onManageMembers,
|
||||
onSeeAllSearchSpaces,
|
||||
onUserSettings,
|
||||
onLogout,
|
||||
pageUsage,
|
||||
className,
|
||||
}: SidebarProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col bg-sidebar text-sidebar-foreground transition-all duration-200 overflow-hidden",
|
||||
isCollapsed ? "w-[60px]" : "w-[240px]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header - search space name or collapse button when collapsed */}
|
||||
{isCollapsed ? (
|
||||
<div className="flex h-14 shrink-0 items-center justify-center border-b">
|
||||
<SidebarCollapseButton
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={onToggleCollapse ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-14 shrink-0 items-center justify-between px-1 border-b">
|
||||
<SidebarHeader
|
||||
searchSpace={searchSpace}
|
||||
isCollapsed={isCollapsed}
|
||||
onSettings={onSettings}
|
||||
onManageMembers={onManageMembers}
|
||||
onSeeAllSearchSpaces={onSeeAllSearchSpaces}
|
||||
/>
|
||||
<div className="">
|
||||
<SidebarCollapseButton
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={onToggleCollapse ?? (() => {})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New chat button */}
|
||||
<div className="p-2">
|
||||
{isCollapsed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="w-full h-10" onClick={onNewChat}>
|
||||
<PenSquare className="h-4 w-4" />
|
||||
<span className="sr-only">{t("new_chat")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t("new_chat")}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button variant="outline" className="w-full justify-start gap-2" onClick={onNewChat}>
|
||||
<PenSquare className="h-4 w-4" />
|
||||
{t("new_chat")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Platform navigation */}
|
||||
{navItems.length > 0 && (
|
||||
<NavSection items={navItems} onItemClick={onNavItemClick} isCollapsed={isCollapsed} />
|
||||
)}
|
||||
|
||||
{/* Scrollable content */}
|
||||
<ScrollArea className="flex-1">
|
||||
{isCollapsed ? (
|
||||
<div className="flex flex-col items-center gap-2 py-2 w-[60px]">
|
||||
{chats.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onToggleCollapse?.()}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<span className="sr-only">{t("recent_chats")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t("recent_chats")} ({chats.length})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{notes.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => onToggleCollapse?.()}
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="sr-only">{t("notes")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t("notes")} ({notes.length})
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 py-2 w-[240px]">
|
||||
<SidebarSection
|
||||
title={t("recent_chats")}
|
||||
defaultOpen={true}
|
||||
action={
|
||||
onViewAllChats && chats.length > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={onViewAllChats}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t("view_all_chats")}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{chats.length > 0 ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{chats.map((chat) => (
|
||||
<ChatListItem
|
||||
key={chat.id}
|
||||
name={chat.name}
|
||||
isActive={chat.id === activeChatId}
|
||||
onClick={() => onChatSelect(chat)}
|
||||
onDelete={() => onChatDelete?.(chat)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_recent_chats")}</p>
|
||||
)}
|
||||
</SidebarSection>
|
||||
|
||||
<SidebarSection
|
||||
title={t("notes")}
|
||||
defaultOpen={true}
|
||||
action={
|
||||
onViewAllNotes && notes.length > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={onViewAllNotes}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t("view_all_notes")}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
persistentAction={
|
||||
onAddNote && notes.length > 0 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={onAddNote}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">{t("add_note")}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{notes.length > 0 ? (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{notes.map((note) => (
|
||||
<NoteListItem
|
||||
key={note.id}
|
||||
name={note.name}
|
||||
isActive={note.id === activeNoteId}
|
||||
isReindexing={note.isReindexing}
|
||||
onClick={() => onNoteSelect(note)}
|
||||
onDelete={() => onNoteDelete?.(note)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : onAddNote ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddNote}
|
||||
className="flex items-center gap-2 px-2 py-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t("create_new_note")}
|
||||
</button>
|
||||
) : (
|
||||
<p className="px-2 py-1 text-xs text-muted-foreground">{t("no_notes")}</p>
|
||||
)}
|
||||
</SidebarSection>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-auto">
|
||||
{pageUsage && !isCollapsed && (
|
||||
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
|
||||
)}
|
||||
|
||||
<SidebarUserProfile
|
||||
user={user}
|
||||
onUserSettings={onUserSettings}
|
||||
onLogout={onLogout}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { PanelLeft, PanelLeftClose } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
interface SidebarCollapseButtonProps {
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function SidebarCollapseButton({ isCollapsed, onToggle }: SidebarCollapseButtonProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" onClick={onToggle} className="h-8 w-8 shrink-0">
|
||||
{isCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||
<span className="sr-only">
|
||||
{isCollapsed ? t("expand_sidebar") : t("collapse_sidebar")}
|
||||
</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={isCollapsed ? "right" : "bottom"}>
|
||||
{isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
71
surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
Normal file
71
surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronsUpDown, LayoutGrid, Settings, Users } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SearchSpace } from "../../types/layout.types";
|
||||
|
||||
interface SidebarHeaderProps {
|
||||
searchSpace: SearchSpace | null;
|
||||
isCollapsed?: boolean;
|
||||
onSettings?: () => void;
|
||||
onManageMembers?: () => void;
|
||||
onSeeAllSearchSpaces?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SidebarHeader({
|
||||
searchSpace,
|
||||
isCollapsed,
|
||||
onSettings,
|
||||
onManageMembers,
|
||||
onSeeAllSearchSpaces,
|
||||
className,
|
||||
}: SidebarHeaderProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
|
||||
return (
|
||||
<div className={cn("flex shrink-0 items-center", className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
"flex h-auto items-center justify-between gap-2 overflow-hidden py-1.5 font-semibold",
|
||||
isCollapsed ? "w-10" : "w-50"
|
||||
)}
|
||||
>
|
||||
<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={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("search_space_settings")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onSeeAllSearchSpaces}>
|
||||
<LayoutGrid className="mr-2 h-4 w-4" />
|
||||
{t("see_all_search_spaces")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarSectionProps {
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
persistentAction?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SidebarSection({
|
||||
title,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
action,
|
||||
persistentAction,
|
||||
}: SidebarSectionProps) {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen} onOpenChange={setIsOpen} className="overflow-hidden">
|
||||
<div className="flex items-center group/section">
|
||||
<CollapsibleTrigger className="flex flex-1 items-center gap-1.5 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors min-w-0">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"h-3.5 w-3.5 shrink-0 transition-transform duration-200",
|
||||
isOpen && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
<span className="uppercase tracking-wider truncate">{title}</span>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
{/* Action button - visible on hover (always visible on mobile) */}
|
||||
{action && (
|
||||
<div className="shrink-0 opacity-0 group-hover/section:opacity-100 transition-opacity pr-1 flex items-center gap-0.5">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Persistent action - always visible */}
|
||||
{persistentAction && (
|
||||
<div className="shrink-0 pr-1 flex items-center gap-0.5">{persistentAction}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CollapsibleContent className="overflow-hidden">
|
||||
<div className="px-2 pb-2">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronUp, LogOut, Settings } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { User } from "../../types/layout.types";
|
||||
|
||||
interface SidebarUserProfileProps {
|
||||
user: User;
|
||||
onUserSettings?: () => void;
|
||||
onLogout?: () => void;
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a consistent color based on email
|
||||
*/
|
||||
function stringToColor(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const colors = [
|
||||
"#6366f1",
|
||||
"#8b5cf6",
|
||||
"#a855f7",
|
||||
"#d946ef",
|
||||
"#ec4899",
|
||||
"#f43f5e",
|
||||
"#ef4444",
|
||||
"#f97316",
|
||||
"#eab308",
|
||||
"#84cc16",
|
||||
"#22c55e",
|
||||
"#14b8a6",
|
||||
"#06b6d4",
|
||||
"#0ea5e9",
|
||||
"#3b82f6",
|
||||
];
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets initials from email
|
||||
*/
|
||||
function getInitials(email: string): string {
|
||||
const name = email.split("@")[0];
|
||||
const parts = name.split(/[._-]/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function SidebarUserProfile({
|
||||
user,
|
||||
onUserSettings,
|
||||
onLogout,
|
||||
isCollapsed = false,
|
||||
}: SidebarUserProfileProps) {
|
||||
const t = useTranslations("sidebar");
|
||||
const bgColor = stringToColor(user.email);
|
||||
const initials = getInitials(user.email);
|
||||
const displayName = user.name || user.email.split("@")[0];
|
||||
|
||||
// Collapsed view - just show avatar with dropdown
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="border-t p-2">
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-center rounded-md",
|
||||
"hover:bg-accent transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
<span className="sr-only">{displayName}</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{displayName}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<DropdownMenuContent className="w-56" side="right" align="end" sideOffset={8}>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{displayName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<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")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded view
|
||||
return (
|
||||
<div className="border-t">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 px-2 py-3 text-left",
|
||||
"hover:bg-accent transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
|
||||
{/* Name and email */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{displayName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
|
||||
{/* Chevron icon */}
|
||||
<ChevronUp className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent className="w-56" side="top" align="start" sideOffset={4}>
|
||||
<DropdownMenuLabel className="font-normal">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white"
|
||||
style={{ backgroundColor: bgColor }}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium">{displayName}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
|
||||
<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")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
surfsense_web/components/layout/ui/sidebar/index.ts
Normal file
12
surfsense_web/components/layout/ui/sidebar/index.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
export { AllChatsSidebar } from "./AllChatsSidebar";
|
||||
export { AllNotesSidebar } from "./AllNotesSidebar";
|
||||
export { ChatListItem } from "./ChatListItem";
|
||||
export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar";
|
||||
export { NavSection } from "./NavSection";
|
||||
export { NoteListItem } from "./NoteListItem";
|
||||
export { PageUsageDisplay } from "./PageUsageDisplay";
|
||||
export { Sidebar } from "./Sidebar";
|
||||
export { SidebarCollapseButton } from "./SidebarCollapseButton";
|
||||
export { SidebarHeader } from "./SidebarHeader";
|
||||
export { SidebarSection } from "./SidebarSection";
|
||||
export { SidebarUserProfile } from "./SidebarUserProfile";
|
||||
|
|
@ -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 }}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue