diff --git a/surfsense_backend/alembic/versions/22_add_cometapi_to_litellmprovider_enum.py b/surfsense_backend/alembic/versions/22_add_cometapi_to_litellmprovider_enum.py new file mode 100644 index 000000000..a0cd85b08 --- /dev/null +++ b/surfsense_backend/alembic/versions/22_add_cometapi_to_litellmprovider_enum.py @@ -0,0 +1,47 @@ +"""Add COMETAPI to LiteLLMProvider enum + +Revision ID: 22 +Revises: 21 +""" + +from collections.abc import Sequence + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "22" +down_revision: str | None = "21" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Add COMETAPI to LiteLLMProvider enum.""" + + # Add COMETAPI to the enum if it doesn't already exist + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum + WHERE enumtypid = 'litellmprovider'::regtype + AND enumlabel = 'COMETAPI' + ) THEN + ALTER TYPE litellmprovider ADD VALUE 'COMETAPI'; + END IF; + END$$; + """ + ) + + +def downgrade() -> None: + """Remove COMETAPI from LiteLLMProvider enum. + + Note: PostgreSQL doesn't support removing enum values directly. + This would require recreating the enum type and updating all dependent objects. + For safety, this downgrade is a no-op. + """ + # PostgreSQL doesn't support removing enum values directly + # This would require a complex migration recreating the enum + pass diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 457d95544..810723755 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -102,6 +102,7 @@ class LiteLLMProvider(str, Enum): NLPCLOUD = "NLPCLOUD" ALEPH_ALPHA = "ALEPH_ALPHA" PETALS = "PETALS" + COMETAPI = "COMETAPI" CUSTOM = "CUSTOM" diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py index 3ea1e8e61..dd7b56033 100644 --- a/surfsense_backend/app/routes/documents_routes.py +++ b/surfsense_backend/app/routes/documents_routes.py @@ -22,6 +22,7 @@ from app.schemas import ( DocumentsCreate, DocumentUpdate, DocumentWithChunksRead, + PaginatedResponse, ) from app.services.task_logging_service import TaskLoggingService from app.tasks.document_processors import ( @@ -154,15 +155,36 @@ async def create_documents_file_upload( ) from e -@router.get("/documents/", response_model=list[DocumentRead]) +@router.get("/documents/", response_model=PaginatedResponse[DocumentRead]) async def read_documents( - skip: int = 0, - limit: int = 300, + skip: int | None = None, + page: int | None = None, + page_size: int = 50, search_space_id: int | None = None, session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), ): + """ + List documents owned by the current user, with optional filtering and pagination. + + Args: + skip: Absolute number of items to skip from the beginning. If provided, it takes precedence over 'page'. + page: Zero-based page index used when 'skip' is not provided. + page_size: Number of items per page (default: 50). Use -1 to return all remaining items after the offset. + search_space_id: If provided, restrict results to a specific search space. + session: Database session (injected). + user: Current authenticated user (injected). + + Returns: + PaginatedResponse[DocumentRead]: Paginated list of documents visible to the user. + + Notes: + - If both 'skip' and 'page' are provided, 'skip' is used. + - Results are scoped to documents owned by the current user. + """ try: + from sqlalchemy import func + query = ( select(Document).join(SearchSpace).filter(SearchSpace.user_id == user.id) ) @@ -171,7 +193,33 @@ async def read_documents( if search_space_id is not None: query = query.filter(Document.search_space_id == search_space_id) - result = await session.execute(query.offset(skip).limit(limit)) + # Get total count + count_query = ( + select(func.count()) + .select_from(Document) + .join(SearchSpace) + .filter(SearchSpace.user_id == user.id) + ) + if search_space_id is not None: + count_query = count_query.filter( + Document.search_space_id == search_space_id + ) + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + # Calculate offset + offset = 0 + if skip is not None: + offset = skip + elif page is not None: + offset = page * page_size + + # Get paginated results + if page_size == -1: + result = await session.execute(query.offset(offset)) + else: + result = await session.execute(query.offset(offset).limit(page_size)) + db_documents = result.scalars().all() # Convert database objects to API-friendly format @@ -189,13 +237,106 @@ async def read_documents( ) ) - return api_documents + return PaginatedResponse(items=api_documents, total=total) except Exception as e: raise HTTPException( status_code=500, detail=f"Failed to fetch documents: {e!s}" ) from e +@router.get("/documents/search/", response_model=PaginatedResponse[DocumentRead]) +async def search_documents( + title: str, + skip: int | None = None, + page: int | None = None, + page_size: int = 50, + search_space_id: int | None = None, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """ + Search documents by title substring, optionally filtered by search_space_id. + + Args: + title: Case-insensitive substring to match against document titles. Required. + skip: Absolute number of items to skip from the beginning. If provided, it takes precedence over 'page'. Default: None. + page: Zero-based page index used when 'skip' is not provided. Default: None. + page_size: Number of items per page. Use -1 to return all remaining items after the offset. Default: 50. + search_space_id: Filter results to a specific search space. Default: None. + session: Database session (injected). + user: Current authenticated user (injected). + + Returns: + PaginatedResponse[DocumentRead]: Paginated list of documents matching the query and filter. + + Notes: + - Title matching uses ILIKE (case-insensitive). + - If both 'skip' and 'page' are provided, 'skip' is used. + """ + try: + from sqlalchemy import func + + query = ( + select(Document).join(SearchSpace).filter(SearchSpace.user_id == user.id) + ) + if search_space_id is not None: + query = query.filter(Document.search_space_id == search_space_id) + + # Only search by title (case-insensitive) + query = query.filter(Document.title.ilike(f"%{title}%")) + + # Get total count + count_query = ( + select(func.count()) + .select_from(Document) + .join(SearchSpace) + .filter(SearchSpace.user_id == user.id) + ) + if search_space_id is not None: + count_query = count_query.filter( + Document.search_space_id == search_space_id + ) + count_query = count_query.filter(Document.title.ilike(f"%{title}%")) + total_result = await session.execute(count_query) + total = total_result.scalar() or 0 + + # Calculate offset + offset = 0 + if skip is not None: + offset = skip + elif page is not None: + offset = page * page_size + + # Get paginated results + if page_size == -1: + result = await session.execute(query.offset(offset)) + else: + result = await session.execute(query.offset(offset).limit(page_size)) + + db_documents = result.scalars().all() + + # Convert database objects to API-friendly format + api_documents = [] + for doc in db_documents: + api_documents.append( + DocumentRead( + id=doc.id, + title=doc.title, + document_type=doc.document_type, + document_metadata=doc.document_metadata, + content=doc.content, + created_at=doc.created_at, + search_space_id=doc.search_space_id, + ) + ) + + return PaginatedResponse(items=api_documents, total=total) + except Exception as e: + raise HTTPException( + status_code=500, detail=f"Failed to search documents: {e!s}" + ) from e + + @router.get("/documents/{document_id}", response_model=DocumentRead) async def read_document( document_id: int, diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index ca9287a16..41b2ce23c 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -16,6 +16,7 @@ from .documents import ( DocumentWithChunksRead, ExtensionDocumentContent, ExtensionDocumentMetadata, + PaginatedResponse, ) from .llm_config import LLMConfigBase, LLMConfigCreate, LLMConfigRead, LLMConfigUpdate from .logs import LogBase, LogCreate, LogFilter, LogRead, LogUpdate @@ -68,6 +69,7 @@ __all__ = [ "LogFilter", "LogRead", "LogUpdate", + "PaginatedResponse", "PodcastBase", "PodcastCreate", "PodcastGenerateRequest", diff --git a/surfsense_backend/app/schemas/documents.py b/surfsense_backend/app/schemas/documents.py index bdaf568e7..fc83d24be 100644 --- a/surfsense_backend/app/schemas/documents.py +++ b/surfsense_backend/app/schemas/documents.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import TypeVar from pydantic import BaseModel, ConfigDict @@ -6,6 +7,8 @@ from app.db import DocumentType from .chunks import ChunkRead +T = TypeVar("T") + class ExtensionDocumentMetadata(BaseModel): BrowsingSessionId: str @@ -53,3 +56,8 @@ class DocumentWithChunksRead(DocumentRead): chunks: list[ChunkRead] = [] model_config = ConfigDict(from_attributes=True) + + +class PaginatedResponse[T](BaseModel): + items: list[T] + total: int diff --git a/surfsense_backend/app/services/llm_service.py b/surfsense_backend/app/services/llm_service.py index cbf967791..9135e49dc 100644 --- a/surfsense_backend/app/services/llm_service.py +++ b/surfsense_backend/app/services/llm_service.py @@ -1,6 +1,6 @@ import logging -from langchain_community.chat_models import ChatLiteLLM +from langchain_litellm import ChatLiteLLM from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select @@ -81,6 +81,7 @@ async def get_user_llm_instance( "MISTRAL": "mistral", "AZURE_OPENAI": "azure", "OPENROUTER": "openrouter", + "COMETAPI": "cometapi", # Add more mappings as needed } provider_prefix = provider_map.get( diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml index fe5691b6d..aa109492d 100644 --- a/surfsense_backend/pyproject.toml +++ b/surfsense_backend/pyproject.toml @@ -21,7 +21,6 @@ dependencies = [ "langchain-unstructured>=0.1.6", "langgraph>=0.3.29", "linkup-sdk>=0.2.4", - "litellm>=1.61.4,<1.70.0", "llama-cloud-services>=0.6.25", "markdownify>=0.14.1", "notion-client>=2.3.0", @@ -42,6 +41,8 @@ dependencies = [ "uvicorn[standard]>=0.34.0", "validators>=0.34.0", "youtube-transcript-api>=1.0.3", + "litellm>=1.77.5", + "langchain-litellm>=0.2.3", "elasticsearch>=9.1.1", ] diff --git a/surfsense_backend/uv.lock b/surfsense_backend/uv.lock index 349e93a1a..8c2f8da0d 100644 --- a/surfsense_backend/uv.lock +++ b/surfsense_backend/uv.lock @@ -1259,6 +1259,36 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/84/9c2917a70ed570ddbfd1d32ac23200c1d011e36c332e59950d2f6d204941/fastavro-1.11.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1bc2824e9969c04ab6263d269a1e0e5d40b9bd16ade6b70c29d6ffbc4f3cc102", size = 3387171, upload-time = "2025-05-18T04:55:32.531Z" }, ] +[[package]] +name = "fastuuid" +version = "0.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/80/3c16a1edad2e6cd82fbd15ac998cc1b881f478bf1f80ca717d941c441874/fastuuid-0.13.5.tar.gz", hash = "sha256:d4976821ab424d41542e1ea39bc828a9d454c3f8a04067c06fca123c5b95a1a1", size = 18255 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/36/434f137c5970cac19e57834e1f7680e85301619d49891618c00666700c61/fastuuid-0.13.5-cp312-cp312-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:35fe8045e866bc6846f8de6fa05acb1de0c32478048484a995e96d31e21dff2a", size = 494638 }, + { url = "https://files.pythonhosted.org/packages/ca/3c/083de2ac007b2b305523b9c006dba5051e5afd87a626ef1a39f76e2c6b82/fastuuid-0.13.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:02a460333f52d731a006d18a52ef6fcb2d295a1f5b1a5938d30744191b2f77b7", size = 253138 }, + { url = "https://files.pythonhosted.org/packages/73/5e/630cffa1c8775db526e39e9e4c5c7db0c27be0786bb21ba82c912ae19f63/fastuuid-0.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:74b0e4f8c307b9f477a5d7284db4431ce53a3c1e3f4173db7a97db18564a6202", size = 244521 }, + { url = "https://files.pythonhosted.org/packages/4d/51/55d78705f4fbdadf88fb40f382f508d6c7a4941ceddd7825fafebb4cc778/fastuuid-0.13.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6955a99ef455c2986f3851f4e0ccc35dec56ac1a7720f2b92e88a75d6684512e", size = 271557 }, + { url = "https://files.pythonhosted.org/packages/6a/2b/1b89e90a8635e5587ccdbbeb169c590672ce7637880f2c047482a0359950/fastuuid-0.13.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f10c77b826738c1a27dcdaa92ea4dc1ec9d869748a99e1fde54f1379553d4854", size = 272334 }, + { url = "https://files.pythonhosted.org/packages/0c/06/4c8207894eeb30414999e5c3f66ac039bc4003437eb4060d8a1bceb4cc6f/fastuuid-0.13.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bb25dccbeb249d16d5e664f65f17ebec05136821d5ef462c4110e3f76b86fb86", size = 290594 }, + { url = "https://files.pythonhosted.org/packages/50/69/96d221931a31d77a47cc2487bdfacfb3091edfc2e7a04b1795df1aec05df/fastuuid-0.13.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:a5becc646a3eeafb76ce0a6783ba190cd182e3790a8b2c78ca9db2b5e87af952", size = 452835 }, + { url = "https://files.pythonhosted.org/packages/25/ef/bf045f0a47dcec96247497ef3f7a31d86ebc074330e2dccc34b8dbc0468a/fastuuid-0.13.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:69b34363752d06e9bb0dbdf02ae391ec56ac948c6f2eb00be90dad68e80774b9", size = 468225 }, + { url = "https://files.pythonhosted.org/packages/30/46/4817ab5a3778927155a4bde92540d4c4fa996161ec8b8e080c8928b0984e/fastuuid-0.13.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57d0768afcad0eab8770c9b8cf904716bd3c547e8b9a4e755ee8a673b060a3a3", size = 444907 }, + { url = "https://files.pythonhosted.org/packages/80/27/ab284117ce4dc9b356a7196bdbf220510285f201d27f1f078592cdc8187b/fastuuid-0.13.5-cp312-cp312-win32.whl", hash = "sha256:8ac6c6f5129d52eaa6ef9ea4b6e2f7c69468a053f3ab8e439661186b9c06bb85", size = 145415 }, + { url = "https://files.pythonhosted.org/packages/f4/0c/f970a4222773b248931819f8940800b760283216ca3dda173ed027e94bdd/fastuuid-0.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:ad630e97715beefef07ec37c9c162336e500400774e2c1cbe1a0df6f80d15b9a", size = 150840 }, + { url = "https://files.pythonhosted.org/packages/4f/62/74fc53f6e04a4dc5b36c34e4e679f85a4c14eec800dcdb0f2c14b5442217/fastuuid-0.13.5-cp313-cp313-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ea17dfd35e0e91920a35d91e65e5f9c9d1985db55ac4ff2f1667a0f61189cefa", size = 494678 }, + { url = "https://files.pythonhosted.org/packages/09/ba/f28b9b7045738a8bfccfb9cd6aff4b91fce2669e6b383a48b0694ee9b3ff/fastuuid-0.13.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:be6ad91e5fefbcc2a4b478858a2715e386d405834ea3ae337c3b6b95cc0e47d6", size = 253162 }, + { url = "https://files.pythonhosted.org/packages/b1/18/13fac89cb4c9f0cd7e81a9154a77ecebcc95d2b03477aa91d4d50f7227ee/fastuuid-0.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ea6df13a306aab3e0439d58c312ff1e6f4f07f09f667579679239b4a6121f64a", size = 244546 }, + { url = "https://files.pythonhosted.org/packages/04/bf/9691167804d59411cc4269841df949f6dd5e76452ab10dcfcd1dbe04c5bc/fastuuid-0.13.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2354c1996d3cf12dc2ba3752e2c4d6edc46e1a38c63893146777b1939f3062d4", size = 271528 }, + { url = "https://files.pythonhosted.org/packages/a9/b5/7a75a03d1c7aa0b6d573032fcca39391f0aef7f2caabeeb45a672bc0bd3c/fastuuid-0.13.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6cf9b7469fc26d1f9b1c43ac4b192e219e85b88fdf81d71aa755a6c08c8a817", size = 272292 }, + { url = "https://files.pythonhosted.org/packages/c0/db/fa0f16cbf76e6880599533af4ef01bb586949c5320612e9d884eff13e603/fastuuid-0.13.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92ba539170097b9047551375f1ca09d8d2b4aefcc79eeae3e1c43fe49b42072e", size = 290466 }, + { url = "https://files.pythonhosted.org/packages/1e/02/6b8c45bfbc8500994dd94edba7f59555f9683c4d8c9a164ae1d25d03c7c7/fastuuid-0.13.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:dbb81d05617bc2970765c1ad82db7e8716f6a2b7a361a14b83de5b9240ade448", size = 452838 }, + { url = "https://files.pythonhosted.org/packages/27/12/85d95a84f265b888e8eb9f9e2b5aaf331e8be60c0a7060146364b3544b6a/fastuuid-0.13.5-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:d973bd6bf9d754d3cca874714ac0a6b22a47f239fb3d3c8687569db05aac3471", size = 468149 }, + { url = "https://files.pythonhosted.org/packages/ad/da/dd9a137e9ea707e883c92470113a432233482ec9ad3e9b99c4defc4904e6/fastuuid-0.13.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e725ceef79486423f05ee657634d4b4c1ca5fb2c8a94e0708f5d6356a83f2a83", size = 444933 }, + { url = "https://files.pythonhosted.org/packages/12/f4/ab363d7f4ac3989691e2dc5ae2d8391cfb0b4169e52ef7fa0ac363e936f0/fastuuid-0.13.5-cp313-cp313-win32.whl", hash = "sha256:a1c430a332ead0b2674f1ef71b17f43b8139ec5a4201182766a21f131a31e021", size = 145462 }, + { url = "https://files.pythonhosted.org/packages/aa/8a/52eb77d9c294a54caa0d2d8cc9f906207aa6d916a22de963687ab6db8b86/fastuuid-0.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:241fdd362fd96e6b337db62a65dd7cb3dfac20adf854573247a47510e192db6f", size = 150923 }, +] + [[package]] name = "filelock" version = "3.18.0" @@ -2235,6 +2265,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/f0/31db18b7b8213266aed926ce89b5bdd84ccde7ee2edf4cab14e3dd2bfcf1/langchain_core-0.3.65-py3-none-any.whl", hash = "sha256:80e8faf6e9f331f8ef728f3fe793549f1d3fb244fcf9e1bdcecab6a6f4669394", size = 438052, upload-time = "2025-06-10T20:08:27.393Z" }, ] +[[package]] +name = "langchain-litellm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "litellm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/8f/08032033cd4bdff1d177d6a9e3a1021e47c4c63fd1d8c564af6f3c7e9f8d/langchain_litellm-0.2.3.tar.gz", hash = "sha256:0e11687373ae6a99efee5a04d3a76de4fab0e1459edc0e84adb6f60ca76ebf79", size = 10829 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/26/271b1dad80b39a0e9df7ab13f63fa3fad52ce8288ddf73dec32a2212219f/langchain_litellm-0.2.3-py3-none-any.whl", hash = "sha256:422254b8742893aed6380f5ee73e6ae77869b218758edd0888d14ebd2c439352", size = 11571 }, +] + [[package]] name = "langchain-text-splitters" version = "0.3.8" @@ -2404,11 +2447,12 @@ wheels = [ [[package]] name = "litellm" -version = "1.69.3" +version = "1.77.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, { name = "click" }, + { name = "fastuuid" }, { name = "httpx" }, { name = "importlib-metadata" }, { name = "jinja2" }, @@ -2419,9 +2463,9 @@ dependencies = [ { name = "tiktoken" }, { name = "tokenizers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/25/2ce407b0ef7e3a2b05300336b7264ec781872c5e60e6ead7a10c9410adcd/litellm-1.69.3.tar.gz", hash = "sha256:748fe9dffea743bf683c9e28e4632c14894863e62c3fbf057560ea7324d89390", size = 7493451, upload-time = "2025-05-15T14:09:44.491Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/a3/85fc92d998ec9645c9fac108618681ef411ca4b338cc7544d6b3aad57699/litellm-1.77.5.tar.gz", hash = "sha256:8e8a83b49c4a6ae044b1a1c01adfbdef72b0031b86f1463dd743e267fa1d7b99", size = 10351819, upload-time = "2025-05-15T14:09:44.491Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/c0/d1538ca18a32adb2669312342e11066e3ddb7dffdf328c1fad3027f78b3e/litellm-1.69.3-py3-none-any.whl", hash = "sha256:31f17024d06824aa8c1798e2c6ac44b69d4f721cefbc3bcd0d53a4568831075e", size = 7761394, upload-time = "2025-05-15T14:09:41.413Z" }, + { url = "https://files.pythonhosted.org/packages/94/4c/89553f7e375ef39497d86f2266a0cdb37371a07e9e0aa8949f33c15a4198/litellm-1.77.5-py3-none-any.whl", hash = "sha256:07f53964c08d555621d4376cc42330458301ae889bfb6303155dcabc51095fbf", size = 9165458, upload-time = "2025-05-15T14:09:41.413Z" }, ] [[package]] @@ -3351,7 +3395,7 @@ wheels = [ [[package]] name = "openai" -version = "1.75.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -3363,9 +3407,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/99/b1/318f5d4c482f19c5fcbcde190801bfaaaec23413cda0b88a29f6897448ff/openai-1.75.0.tar.gz", hash = "sha256:fb3ea907efbdb1bcfd0c44507ad9c961afd7dce3147292b54505ecfd17be8fd1", size = 429492, upload-time = "2025-04-16T16:49:29.25Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/dd/4d4d46a06943e37c95b6e388237e1e38d1e9aab264ff070f86345d60b7a4/openai-2.1.0.tar.gz", hash = "sha256:47f3463a5047340a989b4c0cd5378054acfca966ff61a96553b22f098e3270a2", size = 572998, upload-time = "2025-04-16T16:49:29.25Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/80/9a/f34f163294345f123673ed03e77c33dee2534f3ac1f9d18120384457304d/openai-1.75.0-py3-none-any.whl", hash = "sha256:fe6f932d2ded3b429ff67cc9ad118c71327db32eb9d32dd723de3acfca337125", size = 646972, upload-time = "2025-04-16T16:49:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/68/83/88f64fc8f037885efa8a629d1215f5bc1f037453bab4d4f823b5533319eb/openai-2.1.0-py3-none-any.whl", hash = "sha256:33172e8c06a4576144ba4137a493807a9ca427421dcabc54ad3aa656daf757d3", size = 964939, upload-time = "2025-04-16T16:49:27.196Z" }, ] [[package]] @@ -5320,6 +5364,7 @@ dependencies = [ { name = "google-auth-oauthlib" }, { name = "kokoro" }, { name = "langchain-community" }, + { name = "langchain-litellm" }, { name = "langchain-unstructured" }, { name = "langgraph" }, { name = "linkup-sdk" }, @@ -5367,10 +5412,11 @@ requires-dist = [ { name = "google-auth-oauthlib", specifier = ">=1.2.1" }, { name = "kokoro", specifier = ">=0.9.4" }, { name = "langchain-community", specifier = ">=0.3.17" }, + { name = "langchain-litellm", specifier = ">=0.2.3" }, { name = "langchain-unstructured", specifier = ">=0.1.6" }, { name = "langgraph", specifier = ">=0.3.29" }, { name = "linkup-sdk", specifier = ">=0.2.4" }, - { name = "litellm", specifier = ">=1.61.4,<1.70.0" }, + { name = "litellm", specifier = ">=1.77.5" }, { name = "llama-cloud-services", specifier = ">=0.6.25" }, { name = "markdownify", specifier = ">=0.14.1" }, { name = "notion-client", specifier = ">=2.3.0" }, diff --git a/surfsense_web/.env.example b/surfsense_web/.env.example index cff9031be..157bfaa37 100644 --- a/surfsense_web/.env.example +++ b/surfsense_web/.env.example @@ -1,3 +1,5 @@ NEXT_PUBLIC_FASTAPI_BACKEND_URL=http://localhost:8000 NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE=LOCAL or GOOGLE -NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING \ No newline at end of file +NEXT_PUBLIC_ETL_SERVICE=UNSTRUCTURED or LLAMACLOUD or DOCLING +# Contact Form Vars - OPTIONAL +DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.sdsf.supabase.co:5432/postgres \ No newline at end of file diff --git a/surfsense_web/app/(home)/contact/page.tsx b/surfsense_web/app/(home)/contact/page.tsx new file mode 100644 index 000000000..44a6e6d74 --- /dev/null +++ b/surfsense_web/app/(home)/contact/page.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { ContactFormGridWithDetails } from "@/components/contact/contact-form"; + +const page = () => { + return ( +
+ +
+ ); +}; + +export default page; diff --git a/surfsense_web/app/(home)/layout.tsx b/surfsense_web/app/(home)/layout.tsx new file mode 100644 index 000000000..c71f61fa3 --- /dev/null +++ b/surfsense_web/app/(home)/layout.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { Footer } from "@/components/homepage/footer"; +import { Navbar } from "@/components/homepage/navbar"; + +export default function HomePageLayout({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} +
+ ); +} diff --git a/surfsense_web/app/login/AmbientBackground.tsx b/surfsense_web/app/(home)/login/AmbientBackground.tsx similarity index 100% rename from surfsense_web/app/login/AmbientBackground.tsx rename to surfsense_web/app/(home)/login/AmbientBackground.tsx diff --git a/surfsense_web/app/login/GoogleLoginButton.tsx b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx similarity index 99% rename from surfsense_web/app/login/GoogleLoginButton.tsx rename to surfsense_web/app/(home)/login/GoogleLoginButton.tsx index ab0e0ddf8..545f279e7 100644 --- a/surfsense_web/app/login/GoogleLoginButton.tsx +++ b/surfsense_web/app/(home)/login/GoogleLoginButton.tsx @@ -1,6 +1,6 @@ "use client"; import { IconBrandGoogleFilled } from "@tabler/icons-react"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { Logo } from "@/components/Logo"; import { AmbientBackground } from "./AmbientBackground"; diff --git a/surfsense_web/app/login/LocalLoginForm.tsx b/surfsense_web/app/(home)/login/LocalLoginForm.tsx similarity index 99% rename from surfsense_web/app/login/LocalLoginForm.tsx rename to surfsense_web/app/(home)/login/LocalLoginForm.tsx index b464bea98..a81b9d793 100644 --- a/surfsense_web/app/login/LocalLoginForm.tsx +++ b/surfsense_web/app/(home)/login/LocalLoginForm.tsx @@ -1,5 +1,5 @@ "use client"; -import { AnimatePresence, motion } from "framer-motion"; +import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; diff --git a/surfsense_web/app/login/page.tsx b/surfsense_web/app/(home)/login/page.tsx similarity index 99% rename from surfsense_web/app/login/page.tsx rename to surfsense_web/app/(home)/login/page.tsx index c9e9d9090..c3f5501a6 100644 --- a/surfsense_web/app/login/page.tsx +++ b/surfsense_web/app/(home)/login/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { AnimatePresence, motion } from "framer-motion"; import { Loader2 } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; import { useSearchParams } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; import { toast } from "sonner"; diff --git a/surfsense_web/app/(home)/page.tsx b/surfsense_web/app/(home)/page.tsx new file mode 100644 index 000000000..8f85774ac --- /dev/null +++ b/surfsense_web/app/(home)/page.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { CTAHomepage } from "@/components/homepage/cta"; +import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid"; +import { FeaturesCards } from "@/components/homepage/features-card"; +import { Footer } from "@/components/homepage/footer"; +import { HeroSection } from "@/components/homepage/hero-section"; +import ExternalIntegrations from "@/components/homepage/integrations"; +import { Navbar } from "@/components/homepage/navbar"; + +export default function HomePage() { + return ( +
+ + + + + +
+ ); +} diff --git a/surfsense_web/app/(home)/pricing/page.tsx b/surfsense_web/app/(home)/pricing/page.tsx new file mode 100644 index 000000000..04837a578 --- /dev/null +++ b/surfsense_web/app/(home)/pricing/page.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import PricingBasic from "@/components/pricing/pricing-section"; + +const page = () => { + return ( +
+ +
+ ); +}; + +export default page; diff --git a/surfsense_web/app/privacy/page.tsx b/surfsense_web/app/(home)/privacy/page.tsx similarity index 100% rename from surfsense_web/app/privacy/page.tsx rename to surfsense_web/app/(home)/privacy/page.tsx diff --git a/surfsense_web/app/register/page.tsx b/surfsense_web/app/(home)/register/page.tsx similarity index 99% rename from surfsense_web/app/register/page.tsx rename to surfsense_web/app/(home)/register/page.tsx index f046e5cff..303d9a378 100644 --- a/surfsense_web/app/register/page.tsx +++ b/surfsense_web/app/(home)/register/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { AnimatePresence, motion } from "framer-motion"; +import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; diff --git a/surfsense_web/app/terms/page.tsx b/surfsense_web/app/(home)/terms/page.tsx similarity index 100% rename from surfsense_web/app/terms/page.tsx rename to surfsense_web/app/(home)/terms/page.tsx diff --git a/surfsense_web/app/api/contact/route.ts b/surfsense_web/app/api/contact/route.ts new file mode 100644 index 000000000..0af47dfe3 --- /dev/null +++ b/surfsense_web/app/api/contact/route.ts @@ -0,0 +1,61 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db } from "@/app/db"; +import { usersTable } from "@/app/db/schema"; + +// Define validation schema matching the database schema +const contactSchema = z.object({ + name: z.string().min(1, "Name is required").max(255, "Name is too long"), + email: z.string().email("Invalid email address").max(255, "Email is too long"), + company: z.string().min(1, "Company is required").max(255, "Company name is too long"), + message: z.string().optional().default(""), +}); + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validate the request body + const validatedData = contactSchema.parse(body); + + // Insert into database + const result = await db + .insert(usersTable) + .values({ + name: validatedData.name, + email: validatedData.email, + company: validatedData.company, + message: validatedData.message, + }) + .returning(); + + return NextResponse.json( + { + success: true, + message: "Contact form submitted successfully", + data: result[0], + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { + success: false, + message: "Validation error", + errors: error.errors, + }, + { status: 400 } + ); + } + + console.error("Error submitting contact form:", error); + return NextResponse.json( + { + success: false, + message: "Failed to submit contact form", + }, + { status: 500 } + ); + } +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index 1222733d0..44a2846bb 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -1,7 +1,6 @@ "use client"; import { format } from "date-fns"; -import { AnimatePresence, motion, type Variants } from "framer-motion"; import { Calendar, CheckCircle, @@ -14,6 +13,7 @@ import { Tag, Trash2, } from "lucide-react"; +import { AnimatePresence, motion, type Variants } from "motion/react"; import { useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "sonner"; diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/(manage)/page.tsx index fb803790b..9786263fa 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/(manage)/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/(manage)/page.tsx @@ -1,8 +1,8 @@ "use client"; import { format } from "date-fns"; -import { motion } from "framer-motion"; import { Calendar as CalendarIcon, Edit, Plus, RefreshCw, Trash2 } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "sonner"; @@ -41,7 +41,7 @@ import { import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; import { cn } from "@/lib/utils"; // Helper function to format date with time diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/[connector_id]/edit/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/[connector_id]/edit/page.tsx index 83736ff71..87844e8c8 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/[connector_id]/edit/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/[connector_id]/edit/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useEffect } from "react"; import { toast } from "sonner"; @@ -20,7 +20,7 @@ import { } from "@/components/ui/card"; import { Form } from "@/components/ui/form"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useConnectorEditPage } from "@/hooks/useConnectorEditPage"; +import { useConnectorEditPage } from "@/hooks/use-connector-edit-page"; // Import Utils, Types, Hook, and Components import { getConnectorTypeDisplay } from "@/lib/connectors/utils"; diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/[connector_id]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/[connector_id]/page.tsx index 4152ff786..1e1a58453 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/[connector_id]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/[connector_id]/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -24,7 +24,7 @@ import { Input } from "@/components/ui/input"; import { type SearchSourceConnector, useSearchSourceConnectors, -} from "@/hooks/useSearchSourceConnectors"; +} from "@/hooks/use-search-source-connectors"; // Define the form schema with Zod const apiConnectorFormSchema = z.object({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/airtable-connector/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/airtable-connector/page.tsx index b787374ef..cd81474ba 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/airtable-connector/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/airtable-connector/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -21,7 +21,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { type SearchSourceConnector, useSearchSourceConnectors, -} from "@/hooks/useSearchSourceConnectors"; +} from "@/hooks/use-search-source-connectors"; export default function AirtableConnectorPage() { const router = useRouter(); diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/clickup-connector/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/clickup-connector/page.tsx index 0914a1c00..d7ce20cbf 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/clickup-connector/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/clickup-connector/page.tsx @@ -22,7 +22,7 @@ import { import { Input } from "@/components/ui/input"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; // Define the form schema with Zod const clickupConnectorFormSchema = z.object({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/confluence-connector/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/confluence-connector/page.tsx index 2eb767bf4..e4784d22c 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/confluence-connector/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/confluence-connector/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -24,7 +24,7 @@ import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; // Define the form schema with Zod const confluenceConnectorFormSchema = z.object({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/discord-connector/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/discord-connector/page.tsx index 12ff1c790..47366bc01 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/discord-connector/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/discord-connector/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -37,7 +37,7 @@ import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; // Define the form schema with Zod const discordConnectorFormSchema = z.object({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/github-connector/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/github-connector/page.tsx index 4fc8e1f41..602cf066f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/github-connector/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/github-connector/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, CircleAlert, Github, Info, ListChecks, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -39,7 +39,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; // Assuming useSearchSourceConnectors hook exists and works similarly -import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; // Define the form schema with Zod for GitHub PAT entry step const githubPatFormSchema = z.object({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/google-calendar-connector/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/google-calendar-connector/page.tsx index b04a5eddc..a190af7b3 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/google-calendar-connector/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/google-calendar-connector/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; @@ -23,7 +23,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { type SearchSourceConnector, useSearchSourceConnectors, -} from "@/hooks/useSearchSourceConnectors"; +} from "@/hooks/use-search-source-connectors"; export default function GoogleCalendarConnectorPage() { const router = useRouter(); diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/google-gmail-connector/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/google-gmail-connector/page.tsx index 43123ec4b..573650db9 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/google-gmail-connector/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/google-gmail-connector/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; @@ -23,7 +23,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { type SearchSourceConnector, useSearchSourceConnectors, -} from "@/hooks/useSearchSourceConnectors"; +} from "@/hooks/use-search-source-connectors"; export default function GoogleGmailConnectorPage() { const router = useRouter(); diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/jira-connector/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/jira-connector/page.tsx index c7d4466b8..ea99c07d6 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/jira-connector/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/jira-connector/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -37,7 +37,7 @@ import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; // Define the form schema with Zod const jiraConnectorFormSchema = z.object({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/linear-connector/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/linear-connector/page.tsx index 9e1581405..71cc97ded 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/linear-connector/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/linear-connector/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -37,7 +37,7 @@ import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; // Define the form schema with Zod const linearConnectorFormSchema = z.object({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/linkup-api/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/linkup-api/page.tsx index 582898e0a..f68fee18c 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/linkup-api/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/linkup-api/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -30,7 +30,7 @@ import { import { Input } from "@/components/ui/input"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; // Define the form schema with Zod const linkupApiFormSchema = z.object({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/luma-connector/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/luma-connector/page.tsx index cc5bde60b..176aadb74 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/luma-connector/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/luma-connector/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, Key, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import Link from "next/link"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; @@ -33,7 +33,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { type SearchSourceConnector, useSearchSourceConnectors, -} from "@/hooks/useSearchSourceConnectors"; +} from "@/hooks/use-search-source-connectors"; // Define the form schema with Zod const lumaConnectorFormSchema = z.object({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/notion-connector/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/notion-connector/page.tsx index dfb66fe9f..d9a9bdf52 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/notion-connector/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/notion-connector/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -37,7 +37,7 @@ import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; // Define the form schema with Zod const notionConnectorFormSchema = z.object({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/page.tsx index d3cee106e..a8bb9cfe8 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/page.tsx @@ -6,7 +6,7 @@ import { IconChevronDown, IconChevronRight, } from "@tabler/icons-react"; -import { AnimatePresence, motion, type Variants } from "framer-motion"; +import { AnimatePresence, motion, type Variants } from "motion/react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { useState } from "react"; diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/serper-api/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/serper-api/page.tsx index 667126e8e..f594406ed 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/serper-api/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/serper-api/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -30,7 +30,7 @@ import { import { Input } from "@/components/ui/input"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; // Define the form schema with Zod const serperApiFormSchema = z.object({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/slack-connector/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/slack-connector/page.tsx index 6e9802003..e27c96da9 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/slack-connector/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/slack-connector/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -37,7 +37,7 @@ import { Input } from "@/components/ui/input"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; // Define the form schema with Zod const slackConnectorFormSchema = z.object({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/tavily-api/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/tavily-api/page.tsx index e2321abcb..1afb7fbed 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/add/tavily-api/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/add/tavily-api/page.tsx @@ -1,8 +1,8 @@ "use client"; import { zodResolver } from "@hookform/resolvers/zod"; -import { motion } from "framer-motion"; import { ArrowLeft, Check, Info, Loader2 } from "lucide-react"; +import { motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { useForm } from "react-hook-form"; @@ -30,7 +30,7 @@ import { import { Input } from "@/components/ui/input"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; // Define the form schema with Zod const tavilyApiFormSchema = z.object({ diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx index dd8791223..3a821b09f 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx @@ -1,7 +1,7 @@ "use client"; -import { AnimatePresence, motion, type Variants } from "framer-motion"; import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react"; +import { AnimatePresence, motion, type Variants } from "motion/react"; import React, { useMemo, useRef } from "react"; import { AlertDialog, diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index d9f9bb251..6d70a7249 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -1,7 +1,7 @@ "use client"; -import { motion } from "framer-motion"; import { ChevronDown, ChevronUp, FileX } from "lucide-react"; +import { motion } from "motion/react"; import React from "react"; import { DocumentViewer } from "@/components/document-viewer"; import { Button } from "@/components/ui/button"; diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls.tsx index 2e6f8f314..2b7434d02 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls.tsx @@ -1,7 +1,7 @@ "use client"; -import { motion } from "framer-motion"; import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react"; +import { motion } from "motion/react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination"; diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx index 4a69a7533..b63a20367 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx @@ -1,8 +1,8 @@ "use client"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { useParams } from "next/navigation"; -import { useEffect, useId, useMemo, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { toast } from "sonner"; import { useDocuments } from "@/hooks/use-documents"; @@ -26,10 +26,6 @@ export default function DocumentsTable() { const params = useParams(); const searchSpaceId = Number(params.search_space_id); - const { documents, loading, error, refreshDocuments, deleteDocument } = - useDocuments(searchSpaceId); - - const [data, setData] = useState([]); const [search, setSearch] = useState(""); const debouncedSearch = useDebounced(search, 250); const [activeTypes, setActiveTypes] = useState([]); @@ -45,26 +41,41 @@ export default function DocumentsTable() { const [sortDesc, setSortDesc] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); - useEffect(() => { - if (documents) setData(documents as Document[]); - }, [documents]); + // Use server-side pagination and search + const { documents, total, loading, error, fetchDocuments, searchDocuments, deleteDocument } = + useDocuments(searchSpaceId, { + page: pageIndex, + pageSize: pageSize, + }); - const filtered = useMemo(() => { - let result = data; - if (debouncedSearch.trim()) { - const q = debouncedSearch.toLowerCase(); - result = result.filter((d) => d.title.toLowerCase().includes(q)); + // Refetch when pagination changes or when search/filters change + useEffect(() => { + if (searchSpaceId) { + if (debouncedSearch.trim()) { + // Use search endpoint if there's a search query + searchDocuments?.(debouncedSearch, pageIndex, pageSize); + } else { + // Use regular fetch if no search + fetchDocuments?.(pageIndex, pageSize); + } } + }, [pageIndex, pageSize, debouncedSearch, searchSpaceId, fetchDocuments, searchDocuments]); + + // Client-side filtering for document types only + // Note: This could also be moved to the backend for better performance + const filtered = useMemo(() => { + let result = documents || []; if (activeTypes.length > 0) { result = result.filter((d) => activeTypes.includes(d.document_type)); } return result; - }, [data, debouncedSearch, activeTypes]); + }, [documents, activeTypes]); - const total = filtered.length; + // Display filtered results + const displayDocs = filtered; + const displayTotal = activeTypes.length > 0 ? filtered.length : total; const pageStart = pageIndex * pageSize; - const pageEnd = Math.min(pageStart + pageSize, total); - const pageDocs = filtered.slice(pageStart, pageEnd); + const pageEnd = Math.min(pageStart + pageSize, displayTotal); const onToggleType = (type: string, checked: boolean) => { setActiveTypes((prev) => (checked ? [...prev, type] : prev.filter((t) => t !== type))); @@ -75,6 +86,14 @@ export default function DocumentsTable() { setColumnVisibility((prev) => ({ ...prev, [id]: checked })); }; + const refreshCurrentView = useCallback(async () => { + if (debouncedSearch.trim()) { + await searchDocuments?.(debouncedSearch, pageIndex, pageSize); + } else { + await fetchDocuments?.(pageIndex, pageSize); + } + }, [debouncedSearch, pageIndex, pageSize, searchDocuments, fetchDocuments]); + const onBulkDelete = async () => { if (selectedIds.size === 0) { toast.error("No rows selected"); @@ -86,7 +105,8 @@ export default function DocumentsTable() { if (okCount === selectedIds.size) toast.success(`Successfully deleted ${okCount} document(s)`); else toast.error("Some documents could not be deleted"); - await refreshDocuments?.(); + // Refetch the current page with appropriate method + await refreshCurrentView(); setSelectedIds(new Set()); } catch (e) { console.error(e); @@ -113,8 +133,8 @@ export default function DocumentsTable() { className="w-full px-6 py-4" > { - await (refreshDocuments?.() ?? Promise.resolve()); - }} + onRefresh={refreshCurrentView} selectedIds={selectedIds} setSelectedIds={setSelectedIds} columnVisibility={columnVisibility} @@ -150,17 +168,17 @@ export default function DocumentsTable() { { setPageSize(s); setPageIndex(0); }} onFirst={() => setPageIndex(0)} onPrev={() => setPageIndex((i) => Math.max(0, i - 1))} - onNext={() => setPageIndex((i) => (pageEnd < total ? i + 1 : i))} - onLast={() => setPageIndex(Math.max(0, Math.ceil(total / pageSize) - 1))} + onNext={() => setPageIndex((i) => (pageEnd < displayTotal ? i + 1 : i))} + onLast={() => setPageIndex(Math.max(0, Math.ceil(displayTotal / pageSize) - 1))} canPrev={pageIndex > 0} - canNext={pageEnd < total} + canNext={pageEnd < displayTotal} id={id} /> diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/upload/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/upload/page.tsx index efea74b49..a70744e83 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/upload/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/upload/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { AnimatePresence, motion } from "framer-motion"; import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useCallback, useState } from "react"; import { useDropzone } from "react-dropzone"; diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/youtube/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/youtube/page.tsx index 494420585..6c73a7215 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/youtube/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/youtube/page.tsx @@ -2,8 +2,8 @@ import { IconBrandYoutube } from "@tabler/icons-react"; import { type Tag, TagInput } from "emblor"; -import { motion, type Variants } from "framer-motion"; import { Loader2 } from "lucide-react"; +import { motion, type Variants } from "motion/react"; import { useParams, useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; diff --git a/surfsense_web/app/dashboard/[search_space_id]/logs/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/logs/(manage)/page.tsx index c1c8fd132..f8a0a593e 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/logs/(manage)/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/logs/(manage)/page.tsx @@ -15,7 +15,6 @@ import { useReactTable, type VisibilityState, } from "@tanstack/react-table"; -import { AnimatePresence, motion, type Variants } from "framer-motion"; import { Activity, AlertCircle, @@ -42,6 +41,7 @@ import { X, Zap, } from "lucide-react"; +import { AnimatePresence, motion, type Variants } from "motion/react"; import { useParams } from "next/navigation"; import React, { useContext, useId, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; diff --git a/surfsense_web/app/dashboard/[search_space_id]/researcher/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/researcher/[[...chat_id]]/page.tsx index a867f5f84..9d3065b0d 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/researcher/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/researcher/[[...chat_id]]/page.tsx @@ -5,8 +5,8 @@ import { useParams, useRouter } from "next/navigation"; import { useEffect, useMemo } from "react"; import type { ResearchMode } from "@/components/chat"; import ChatInterface from "@/components/chat/ChatInterface"; +import { useChatAPI, useChatState } from "@/hooks/use-chat"; import type { Document } from "@/hooks/use-documents"; -import { useChatAPI, useChatState } from "@/hooks/useChat"; export default function ResearcherPage() { const { search_space_id, chat_id } = useParams(); diff --git a/surfsense_web/app/dashboard/api-key/api-key-client.tsx b/surfsense_web/app/dashboard/api-key/api-key-client.tsx index 97a36d8e6..9163b52d8 100644 --- a/surfsense_web/app/dashboard/api-key/api-key-client.tsx +++ b/surfsense_web/app/dashboard/api-key/api-key-client.tsx @@ -1,8 +1,8 @@ "use client"; import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react"; -import { AnimatePresence, motion } from "framer-motion"; 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"; diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index 484b95929..e99454ae0 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { motion, type Variants } from "framer-motion"; import { AlertCircle, Loader2, Plus, Search, Trash2 } from "lucide-react"; +import { motion, type Variants } from "motion/react"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; diff --git a/surfsense_web/app/dashboard/searchspaces/page.tsx b/surfsense_web/app/dashboard/searchspaces/page.tsx index b1f2f390e..598536c1b 100644 --- a/surfsense_web/app/dashboard/searchspaces/page.tsx +++ b/surfsense_web/app/dashboard/searchspaces/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { motion } from "framer-motion"; +import { motion } from "motion/react"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { SearchSpaceForm } from "@/components/search-space-form"; diff --git a/surfsense_web/app/db/index.ts b/surfsense_web/app/db/index.ts new file mode 100644 index 000000000..db86725e1 --- /dev/null +++ b/surfsense_web/app/db/index.ts @@ -0,0 +1,13 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "./schema"; + +// Configure postgres client for Vercel serverless environment +const client = postgres(process.env.DATABASE_URL!, { + max: 1, // Limit connections for serverless (Vercel) + idle_timeout: 20, // Close idle connections after 20 seconds + max_lifetime: 60 * 30, // Close connections after 30 minutes + connect_timeout: 10, // Connection timeout in seconds +}); + +export const db = drizzle({ client, schema }); diff --git a/surfsense_web/app/db/schema.ts b/surfsense_web/app/db/schema.ts new file mode 100644 index 000000000..d8b941ba5 --- /dev/null +++ b/surfsense_web/app/db/schema.ts @@ -0,0 +1,9 @@ +import { integer, pgTable, text, varchar } from "drizzle-orm/pg-core"; + +export const usersTable = pgTable("users", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + name: varchar({ length: 255 }).notNull(), + email: varchar({ length: 255 }).notNull().unique(), + company: varchar({ length: 255 }).notNull(), + message: text().default(""), +}); diff --git a/surfsense_web/app/onboard/page.tsx b/surfsense_web/app/onboard/page.tsx index 997b7e4c8..387bf736a 100644 --- a/surfsense_web/app/onboard/page.tsx +++ b/surfsense_web/app/onboard/page.tsx @@ -1,7 +1,7 @@ "use client"; -import { AnimatePresence, motion } from "framer-motion"; import { ArrowLeft, ArrowRight, Bot, CheckCircle, Sparkles } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; import { Logo } from "@/components/Logo"; diff --git a/surfsense_web/app/page.tsx b/surfsense_web/app/page.tsx deleted file mode 100644 index e5501d35f..000000000 --- a/surfsense_web/app/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import { Footer } from "@/components/Footer"; -import { ModernHeroWithGradients } from "@/components/ModernHeroWithGradients"; -import { Navbar } from "@/components/Navbar"; - -export default function HomePage() { - return ( -
- - -
-
- ); -} diff --git a/surfsense_web/app/sitemap.ts b/surfsense_web/app/sitemap.ts index 1380fc561..63e2b5753 100644 --- a/surfsense_web/app/sitemap.ts +++ b/surfsense_web/app/sitemap.ts @@ -3,43 +3,55 @@ import type { MetadataRoute } from "next"; export default function sitemap(): MetadataRoute.Sitemap { return [ { - url: "https://www.surfsense.net/", + url: "https://www.surfsense.com/", lastModified: new Date(), changeFrequency: "yearly", priority: 1, }, { - url: "https://www.surfsense.net/privacy", + url: "https://www.surfsense.com/contact", + lastModified: new Date(), + changeFrequency: "yearly", + priority: 1, + }, + { + url: "https://www.surfsense.com/pricing", + lastModified: new Date(), + changeFrequency: "yearly", + priority: 0.9, + }, + { + url: "https://www.surfsense.com/privacy", lastModified: new Date(), changeFrequency: "monthly", priority: 0.9, }, { - url: "https://www.surfsense.net/terms", + url: "https://www.surfsense.com/terms", lastModified: new Date(), changeFrequency: "monthly", priority: 0.9, }, { - url: "https://www.surfsense.net/docs", + url: "https://www.surfsense.com/docs", lastModified: new Date(), changeFrequency: "weekly", priority: 0.9, }, { - url: "https://www.surfsense.net/docs/installation", + url: "https://www.surfsense.com/docs/installation", lastModified: new Date(), changeFrequency: "weekly", priority: 0.9, }, { - url: "https://www.surfsense.net/docs/docker-installation", + url: "https://www.surfsense.com/docs/docker-installation", lastModified: new Date(), changeFrequency: "weekly", priority: 0.9, }, { - url: "https://www.surfsense.net/docs/manual-installation", + url: "https://www.surfsense.com/docs/manual-installation", lastModified: new Date(), changeFrequency: "weekly", priority: 0.9, diff --git a/surfsense_web/components/ModernHeroWithGradients.tsx b/surfsense_web/components/ModernHeroWithGradients.tsx deleted file mode 100644 index 5c81cf54b..000000000 --- a/surfsense_web/components/ModernHeroWithGradients.tsx +++ /dev/null @@ -1,541 +0,0 @@ -"use client"; -import { IconBrandDiscord, IconBrandGithub, IconFileTypeDoc } from "@tabler/icons-react"; -import Link from "next/link"; -import { cn } from "@/lib/utils"; -import { Logo } from "./Logo"; - -export function ModernHeroWithGradients() { - return ( -
-
-
- - - - - - - -
-
- - MODSetter%2FSurfSense | Trendshift - -
- - - Documentation - - {/* Import the Logo component or define it in this file */} -
-
- -
-

- SurfSense -

-
-

- A Customizable AI Research Agent just like NotebookLM or Perplexity, but connected to - external sources such as Search Engines, Slack, Linear, Jira, ClickUp, Confluence, - Notion, YouTube, GitHub, Discord and more. -

-
- - - Discord - - - - GitHub - -
-
-
-
-
- ); -} - -const TopLines = () => { - return ( - - Top Lines - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -const BottomLines = () => { - return ( - - Bottom Lines - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -const SideLines = () => { - return ( - - Side Lines - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -const BottomGradient = ({ className }: { className?: string }) => { - return ( - - Bottom Gradient - - - - - - - - - - - ); -}; - -const TopGradient = ({ className }: { className?: string }) => { - return ( - - Top Gradient - - - - - - - - - - - - - - - - - - ); -}; - -const DarkModeGradient = () => { - return ( -
-
-
-
-
- ); -}; diff --git a/surfsense_web/components/Navbar.tsx b/surfsense_web/components/Navbar.tsx deleted file mode 100644 index 8a38ca863..000000000 --- a/surfsense_web/components/Navbar.tsx +++ /dev/null @@ -1,286 +0,0 @@ -"use client"; -import { IconMenu2, IconUser, IconX } from "@tabler/icons-react"; -import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "framer-motion"; -import Link from "next/link"; -import { useRef, useState } from "react"; -import { cn } from "@/lib/utils"; -import { Logo } from "./Logo"; -import { ThemeTogglerComponent } from "./theme/theme-toggle"; -import { Button } from "./ui/button"; - -interface NavbarProps { - navItems: { - name: string; - link: string; - }[]; - visible: boolean; -} - -export const Navbar = () => { - const navItems = [ - { - name: "Docs", - link: "/docs", - }, - // { - // name: "Product", - // link: "/#product", - // }, - // { - // name: "Pricing", - // link: "/#pricing", - // }, - ]; - - const ref = useRef(null); - const { scrollY } = useScroll({ - target: ref, - offset: ["start start", "end start"], - }); - const [visible, setVisible] = useState(false); - - useMotionValueEvent(scrollY, "change", (latest) => { - if (latest > 100) { - setVisible(true); - } else { - setVisible(false); - } - }); - - return ( - - - - - ); -}; - -const DesktopNav = ({ navItems, visible }: NavbarProps) => { - const [hoveredIndex, setHoveredIndex] = useState(null); - - const handleGoogleLogin = () => { - // Redirect to the login page - window.location.href = "/login"; - }; - - return ( - setHoveredIndex(null)} - animate={{ - backdropFilter: "blur(16px)", - background: visible - ? "rgba(var(--background-rgb), 0.8)" - : "rgba(var(--background-rgb), 0.6)", - width: visible ? "38%" : "80%", - height: visible ? "48px" : "64px", - y: visible ? 8 : 0, - }} - initial={{ - width: "80%", - height: "64px", - background: "rgba(var(--background-rgb), 0.6)", - }} - transition={{ - type: "spring", - stiffness: 400, - damping: 30, - }} - className={cn( - "hidden lg:flex flex-row self-center items-center justify-between py-2 mx-auto px-6 rounded-full relative z-[60] backdrop-saturate-[1.8]", - visible ? "border dark:border-white/10 border-gray-300/30" : "border-0" - )} - style={ - { - "--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'", - } as React.CSSProperties - } - > -
- - SurfSense -
-
- - {navItems.map((navItem, idx) => ( - setHoveredIndex(idx)} - className="relative" - > - - {navItem.name} - {hoveredIndex === idx && ( - - )} - - - ))} - - - - {!visible && ( - - - - )} - -
-
- ); -}; - -const MobileNav = ({ navItems, visible }: NavbarProps) => { - const [open, setOpen] = useState(false); - - const handleGoogleLogin = () => { - // Redirect to the login page - window.location.href = "./login"; - }; - - return ( - -
- -
- - {open ? ( - setOpen(!open)} /> - ) : ( - setOpen(!open)} - /> - )} -
-
- - - {open && ( - - {navItems.map((navItem: { link: string; name: string }) => ( - setOpen(false)} - className="relative dark:text-white/90 text-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors" - > - {navItem.name} - - ))} - - - )} - -
- ); -}; diff --git a/surfsense_web/components/chat/AnimatedEmptyState.tsx b/surfsense_web/components/chat/AnimatedEmptyState.tsx index 992b75395..b43532d4e 100644 --- a/surfsense_web/components/chat/AnimatedEmptyState.tsx +++ b/surfsense_web/components/chat/AnimatedEmptyState.tsx @@ -1,6 +1,6 @@ "use client"; -import { useInView } from "framer-motion"; +import { useInView } from "motion/react"; import { Manrope } from "next/font/google"; import { useEffect, useMemo, useReducer, useRef } from "react"; import { RoughNotation, RoughNotationGroup } from "react-rough-notation"; @@ -85,7 +85,6 @@ const initialState: HighlightState = { export function AnimatedEmptyState() { const ref = useRef(null); const isInView = useInView(ref); - const { state: sidebarState } = useSidebar(); const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer( highlightReducer, initialState diff --git a/surfsense_web/components/chat/ChatCitation.tsx b/surfsense_web/components/chat/ChatCitation.tsx index fe3c2b993..d8c681781 100644 --- a/surfsense_web/components/chat/ChatCitation.tsx +++ b/surfsense_web/components/chat/ChatCitation.tsx @@ -1,233 +1,30 @@ "use client"; -import { ChevronDown, ChevronUp, ExternalLink, FileText, Loader2 } from "lucide-react"; import type React from "react"; -import { useEffect, useRef, useState } from "react"; -import { MarkdownViewer } from "@/components/markdown-viewer"; -import { Button } from "@/components/ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, - SheetTrigger, -} from "@/components/ui/sheet"; -import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; -import { useDocumentByChunk } from "@/hooks/use-document-by-chunk"; -import { cn } from "@/lib/utils"; +import { useState } from "react"; +import { SheetTrigger } from "@/components/ui/sheet"; +import { SourceDetailSheet } from "./SourceDetailSheet"; export const CitationDisplay: React.FC<{ index: number; node: any }> = ({ index, node }) => { const chunkId = Number(node?.id); const sourceType = node?.metadata?.source_type; const [isOpen, setIsOpen] = useState(false); - const { document, loading, error, fetchDocumentByChunk, clearDocument } = useDocumentByChunk(); - const chunksContainerRef = useRef(null); - const highlightedChunkRef = useRef(null); - const [summaryOpen, setSummaryOpen] = useState(false); - - // Check if this is a source type that should render directly from node - const isDirectRenderSource = sourceType === "TAVILY_API" || sourceType === "LINKUP_API"; - - const handleOpenChange = async (open: boolean) => { - setIsOpen(open); - if (open && chunkId && !isDirectRenderSource) { - await fetchDocumentByChunk(chunkId); - } else if (!open && !isDirectRenderSource) { - clearDocument(); - } - }; - - useEffect(() => { - // Scroll to highlighted chunk when document loads - if (document && highlightedChunkRef.current && chunksContainerRef.current) { - setTimeout(() => { - highlightedChunkRef.current?.scrollIntoView({ - behavior: "smooth", - block: "start", - }); - }, 100); - } - }, [document]); - - const handleUrlClick = (e: React.MouseEvent, url: string) => { - e.preventDefault(); - e.stopPropagation(); - window.open(url, "_blank", "noopener,noreferrer"); - }; - - const formatDocumentType = (type: string) => { - return type - .split("_") - .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) - .join(" "); - }; return ( - + {index + 1} - - - - {getConnectorIcon(sourceType)} - {document?.title || node?.metadata?.title || node?.metadata?.group_name || "Source"} - - - {document - ? formatDocumentType(document.document_type) - : sourceType && formatDocumentType(sourceType)} - - - - {!isDirectRenderSource && loading && ( -
- -
- )} - - {!isDirectRenderSource && error && ( -
-

{error}

-
- )} - - {/* Direct render for TAVILY_API and LINEAR_API */} - {isDirectRenderSource && ( - -
- {/* External Link */} - {node?.url && ( -
- -
- )} - - {/* Source Information */} -
-

Source Information

-
- {node?.metadata?.title || "Untitled"} -
-
- {node?.text || "No content available"} -
-
-
-
- )} - - {/* API-fetched document content */} - {!isDirectRenderSource && document && ( - -
- {/* Document Metadata */} - {document.document_metadata && Object.keys(document.document_metadata).length > 0 && ( -
-

Document Information

-
- {Object.entries(document.document_metadata).map(([key, value]) => ( -
-
- {key.replace(/_/g, " ")}: -
-
{String(value)}
-
- ))} -
-
- )} - - {/* External Link */} - {node?.url && ( -
- -
- )} - - {/* Chunks */} -
-
- {/* Header row: header and button side by side */} -
-

Document Content

- {document.content && ( - - - Summary - {summaryOpen ? ( - - ) : ( - - )} - - - )} -
- {/* Expanded summary content: always full width, below the row */} - {document.content && ( - - -
- -
-
-
- )} -
- - {document.chunks.map((chunk, idx) => ( -
-
- - Chunk {idx + 1} of {document.chunks.length} - - {chunk.id === chunkId && ( - - Referenced Chunk - - )} -
-
- -
-
- ))} -
-
-
- )} -
-
+ ); }; diff --git a/surfsense_web/components/chat/ChatInputGroup.tsx b/surfsense_web/components/chat/ChatInputGroup.tsx index 1f5cc6d31..fdc03e893 100644 --- a/surfsense_web/components/chat/ChatInputGroup.tsx +++ b/surfsense_web/components/chat/ChatInputGroup.tsx @@ -27,7 +27,7 @@ import { import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { type Document, useDocuments } from "@/hooks/use-documents"; import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs"; -import { useSearchSourceConnectors } from "@/hooks/useSearchSourceConnectors"; +import { useSearchSourceConnectors } from "@/hooks/use-search-source-connectors"; const DocumentSelector = React.memo( ({ @@ -40,10 +40,10 @@ const DocumentSelector = React.memo( const { search_space_id } = useParams(); const [isOpen, setIsOpen] = useState(false); - const { documents, loading, isLoaded, fetchDocuments } = useDocuments( - Number(search_space_id), - true - ); + const { documents, loading, isLoaded, fetchDocuments } = useDocuments(Number(search_space_id), { + lazy: true, + pageSize: -1, // Fetch all documents with large page size + }); const handleOpenChange = useCallback( (open: boolean) => { diff --git a/surfsense_web/components/chat/ChatSources.tsx b/surfsense_web/components/chat/ChatSources.tsx index 625a81fa1..5f205d005 100644 --- a/surfsense_web/components/chat/ChatSources.tsx +++ b/surfsense_web/components/chat/ChatSources.tsx @@ -9,12 +9,14 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { SourceDetailSheet } from "./SourceDetailSheet"; interface Source { id: string; title: string; description: string; url: string; + sourceType: string; } interface SourceGroup { @@ -48,6 +50,9 @@ function getSourceIcon(type: string) { function SourceCard({ source }: { source: Source }) { const hasUrl = source.url && source.url.trim() !== ""; + const chunkId = Number(source.id); + const sourceType = source.sourceType; + const [isOpen, setIsOpen] = useState(false); // Clean up the description for better display const cleanDescription = source.description @@ -55,31 +60,54 @@ function SourceCard({ source }: { source: Source }) { .replace(/\n+/g, " ") .trim(); + const handleUrlClick = (e: React.MouseEvent, url: string) => { + e.preventDefault(); + e.stopPropagation(); + window.open(url, "_blank", "noopener,noreferrer"); + }; + return ( - - -
- - {source.title} - - {hasUrl && ( - - )} -
-
- - - {cleanDescription} - - -
+ + + + +
+ + {source.title} + +
+ + #{chunkId} + + {hasUrl && ( + + )} +
+
+
+ + + {cleanDescription} + + +
+
+
); } @@ -126,6 +154,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) { title: node.metadata.title, description: node.text, url: node.url || "", + sourceType: sourceType, })), }); } diff --git a/surfsense_web/components/chat/SourceDetailSheet.tsx b/surfsense_web/components/chat/SourceDetailSheet.tsx new file mode 100644 index 000000000..4f7d129e4 --- /dev/null +++ b/surfsense_web/components/chat/SourceDetailSheet.tsx @@ -0,0 +1,244 @@ +"use client"; + +import { ChevronDown, ChevronUp, ExternalLink, Loader2 } from "lucide-react"; +import type React from "react"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import { MarkdownViewer } from "@/components/markdown-viewer"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { useDocumentByChunk } from "@/hooks/use-document-by-chunk"; +import { cn } from "@/lib/utils"; + +interface SourceDetailSheetProps { + open: boolean; + onOpenChange: (open: boolean) => void; + chunkId: number; + sourceType: string; + title: string; + description?: string; + url?: string; + children?: ReactNode; +} + +const formatDocumentType = (type: string) => { + return type + .split("_") + .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) + .join(" "); +}; + +export function SourceDetailSheet({ + open, + onOpenChange, + chunkId, + sourceType, + title, + description, + url, + children, +}: SourceDetailSheetProps) { + const { document, loading, error, fetchDocumentByChunk, clearDocument } = useDocumentByChunk(); + const chunksContainerRef = useRef(null); + const highlightedChunkRef = useRef(null); + const [summaryOpen, setSummaryOpen] = useState(false); + + // Check if this is a source type that should render directly from node + const isDirectRenderSource = sourceType === "TAVILY_API" || sourceType === "LINKUP_API"; + + useEffect(() => { + if (open && chunkId && !isDirectRenderSource) { + fetchDocumentByChunk(chunkId); + } else if (!open && !isDirectRenderSource) { + clearDocument(); + } + }, [open, chunkId, isDirectRenderSource, fetchDocumentByChunk, clearDocument]); + + useEffect(() => { + // Scroll to highlighted chunk when document loads + if (document && highlightedChunkRef.current && chunksContainerRef.current) { + setTimeout(() => { + highlightedChunkRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }, 100); + } + }, [document]); + + const handleUrlClick = (e: React.MouseEvent, clickUrl: string) => { + e.preventDefault(); + e.stopPropagation(); + window.open(clickUrl, "_blank", "noopener,noreferrer"); + }; + + return ( + + {children} + + + + {getConnectorIcon(sourceType)} + {document?.title || title} + + + {document + ? formatDocumentType(document.document_type) + : sourceType && formatDocumentType(sourceType)} + + + + {!isDirectRenderSource && loading && ( +
+ +
+ )} + + {!isDirectRenderSource && error && ( +
+

{error}

+
+ )} + + {/* Direct render for TAVILY_API and LINKUP_API */} + {isDirectRenderSource && ( + +
+ {/* External Link */} + {url && ( +
+ +
+ )} + + {/* Source Information */} +
+

Source Information

+
+ {title || "Untitled"} +
+
+ {description || "No content available"} +
+
+
+
+ )} + + {/* API-fetched document content */} + {!isDirectRenderSource && document && ( + +
+ {/* Document Metadata */} + {document.document_metadata && Object.keys(document.document_metadata).length > 0 && ( +
+

Document Information

+
+ {Object.entries(document.document_metadata).map(([key, value]) => ( +
+
+ {key.replace(/_/g, " ")}: +
+
{String(value)}
+
+ ))} +
+
+ )} + + {/* External Link */} + {url && ( +
+ +
+ )} + + {/* Chunks */} +
+
+ {/* Header row: header and button side by side */} +
+

Document Content

+ {document.content && ( + + + Summary + {summaryOpen ? ( + + ) : ( + + )} + + + )} +
+ {/* Expanded summary content: always full width, below the row */} + {document.content && ( + + +
+ +
+
+
+ )} +
+ + {document.chunks.map((chunk, idx) => ( +
+
+ + Chunk {idx + 1} of {document.chunks.length} + + {chunk.id === chunkId && ( + + Referenced Chunk + + )} +
+
+ +
+
+ ))} +
+
+
+ )} +
+
+ ); +} diff --git a/surfsense_web/components/contact/contact-form.tsx b/surfsense_web/components/contact/contact-form.tsx new file mode 100644 index 000000000..0f12a2bd9 --- /dev/null +++ b/surfsense_web/components/contact/contact-form.tsx @@ -0,0 +1,358 @@ +"use client"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { IconMailFilled } from "@tabler/icons-react"; +import { motion } from "motion/react"; +import Image from "next/image"; +import Link from "next/link"; +import type React from "react"; +import { useId, useState } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { cn } from "@/lib/utils"; + +// Define validation schema matching the database schema +const contactFormSchema = z.object({ + name: z.string().min(1, "Name is required").max(255, "Name is too long"), + email: z.string().email("Invalid email address").max(255, "Email is too long"), + company: z.string().min(1, "Company is required").max(255, "Company name is too long"), + message: z.string().optional().default(""), +}); + +type ContactFormData = z.infer; + +export function ContactFormGridWithDetails() { + const [isSubmitting, setIsSubmitting] = useState(false); + + const { + register, + handleSubmit, + formState: { errors }, + reset, + } = useForm({ + resolver: zodResolver(contactFormSchema), + }); + + const onSubmit = async (data: ContactFormData) => { + setIsSubmitting(true); + + try { + const response = await fetch("/api/contact", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + const result = await response.json(); + + if (response.ok) { + toast.success("Message sent successfully!", { + description: "We will get back to you as soon as possible.", + }); + reset(); + } else { + toast.error("Failed to send message", { + description: result.message || "Please try again later.", + }); + } + } catch (error) { + console.error("Error submitting form:", error); + toast.error("Something went wrong", { + description: "Please try again later.", + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+
+
+ + + +
+

+ Contact +

+

+ We'd love to Hear From You. +

+ +
+ + rohan@surfsense.com + +
+ + + https://cal.com/mod-surfsense + +
+
+ + + world map +
+
+
+ +
+ + + {errors.name &&

{errors.name.message}

} +
+
+ + + {errors.email &&

{errors.email.message}

} +
+
+ + + {errors.company &&

{errors.company.message}

} +
+
+ +