mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-20 23:21:06 +02:00
Merge remote-tracking branch 'upstream/main' into feature/elasticsearch-connector
This commit is contained in:
commit
a7e0bad42a
100 changed files with 4874 additions and 1352 deletions
|
|
@ -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
|
||||
|
|
@ -102,6 +102,7 @@ class LiteLLMProvider(str, Enum):
|
|||
NLPCLOUD = "NLPCLOUD"
|
||||
ALEPH_ALPHA = "ALEPH_ALPHA"
|
||||
PETALS = "PETALS"
|
||||
COMETAPI = "COMETAPI"
|
||||
CUSTOM = "CUSTOM"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
||||
|
|
|
|||
60
surfsense_backend/uv.lock
generated
60
surfsense_backend/uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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
|
||||
12
surfsense_web/app/(home)/contact/page.tsx
Normal file
12
surfsense_web/app/(home)/contact/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from "react";
|
||||
import { ContactFormGridWithDetails } from "@/components/contact/contact-form";
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<ContactFormGridWithDetails />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
14
surfsense_web/app/(home)/layout.tsx
Normal file
14
surfsense_web/app/(home)/layout.tsx
Normal file
|
|
@ -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 (
|
||||
<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">
|
||||
<Navbar />
|
||||
{children}
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
@ -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";
|
||||
|
|
@ -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";
|
||||
21
surfsense_web/app/(home)/page.tsx
Normal file
21
surfsense_web/app/(home)/page.tsx
Normal file
|
|
@ -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 (
|
||||
<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">
|
||||
<HeroSection />
|
||||
<FeaturesCards />
|
||||
<FeaturesBentoGrid />
|
||||
<ExternalIntegrations />
|
||||
<CTAHomepage />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
12
surfsense_web/app/(home)/pricing/page.tsx
Normal file
12
surfsense_web/app/(home)/pricing/page.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import React from "react";
|
||||
import PricingBasic from "@/components/pricing/pricing-section";
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<div>
|
||||
<PricingBasic />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default page;
|
||||
|
|
@ -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";
|
||||
61
surfsense_web/app/api/contact/route.ts
Normal file
61
surfsense_web/app/api/contact/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<Document[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounced(search, 250);
|
||||
const [activeTypes, setActiveTypes] = useState<string[]>([]);
|
||||
|
|
@ -45,26 +41,41 @@ export default function DocumentsTable() {
|
|||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(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"
|
||||
>
|
||||
<DocumentsFilters
|
||||
allDocuments={data}
|
||||
visibleDocuments={pageDocs}
|
||||
allDocuments={documents || []}
|
||||
visibleDocuments={displayDocs}
|
||||
selectedIds={selectedIds}
|
||||
onSearch={setSearch}
|
||||
searchValue={search}
|
||||
|
|
@ -126,12 +146,10 @@ export default function DocumentsTable() {
|
|||
/>
|
||||
|
||||
<DocumentsTableShell
|
||||
documents={pageDocs}
|
||||
documents={displayDocs}
|
||||
loading={!!loading}
|
||||
error={!!error}
|
||||
onRefresh={async () => {
|
||||
await (refreshDocuments?.() ?? Promise.resolve());
|
||||
}}
|
||||
onRefresh={refreshCurrentView}
|
||||
selectedIds={selectedIds}
|
||||
setSelectedIds={setSelectedIds}
|
||||
columnVisibility={columnVisibility}
|
||||
|
|
@ -150,17 +168,17 @@ export default function DocumentsTable() {
|
|||
<PaginationControls
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
total={displayTotal}
|
||||
onPageSizeChange={(s) => {
|
||||
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}
|
||||
/>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
13
surfsense_web/app/db/index.ts
Normal file
13
surfsense_web/app/db/index.ts
Normal file
|
|
@ -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 });
|
||||
9
surfsense_web/app/db/schema.ts
Normal file
9
surfsense_web/app/db/schema.ts
Normal file
|
|
@ -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(""),
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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">
|
||||
<Navbar />
|
||||
<ModernHeroWithGradients />
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="relative h-full min-h-[50rem] w-full bg-gray-50 dark:bg-black">
|
||||
<div className="relative z-20 mx-auto w-full px-4 py-6 md:px-8 lg:px-4">
|
||||
<div className="relative my-12 overflow-hidden rounded-3xl bg-white py-16 shadow-sm dark:bg-gray-900/80 dark:shadow-lg dark:shadow-purple-900/10 md:py-48 mx-auto w-full max-w-[95%] xl:max-w-[98%]">
|
||||
<TopLines />
|
||||
<BottomLines />
|
||||
<SideLines />
|
||||
<TopGradient />
|
||||
<BottomGradient />
|
||||
<DarkModeGradient />
|
||||
|
||||
<div className="relative z-20 flex flex-col items-center justify-center overflow-hidden rounded-3xl p-4 md:p-12 lg:p-16">
|
||||
<div className="flex justify-center w-full mb-4">
|
||||
<Link
|
||||
href="https://github.com/MODSetter/SurfSense"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src="https://trendshift.io/api/badge/repositories/13606"
|
||||
alt="MODSetter%2FSurfSense | Trendshift"
|
||||
style={{ width: "250px", height: "55px" }}
|
||||
width={250}
|
||||
height={55}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/docs"
|
||||
className="flex items-center gap-1 rounded-full border border-gray-200 bg-gradient-to-b from-gray-50 to-gray-100 px-4 py-1 text-center text-sm text-gray-800 shadow-sm dark:border-[#404040] dark:bg-gradient-to-b dark:from-[#5B5B5D] dark:to-[#262627] dark:text-white dark:shadow-inner dark:shadow-purple-500/10"
|
||||
>
|
||||
<IconFileTypeDoc className="h-4 w-4 text-gray-800 dark:text-white" />
|
||||
<span>Documentation</span>
|
||||
</Link>
|
||||
{/* Import the Logo component or define it in this file */}
|
||||
<div className="flex items-center justify-center gap-4 mt-10 mb-2">
|
||||
<div className="h-16 w-16">
|
||||
<Logo className="rounded-md" />
|
||||
</div>
|
||||
<h1 className="bg-gradient-to-b from-gray-800 to-gray-600 bg-clip-text py-4 text-center text-3xl text-transparent dark:from-white dark:to-purple-300 md:text-5xl lg:text-8xl">
|
||||
SurfSense
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mx-auto max-w-3xl py-6 text-center text-base text-gray-600 dark:text-neutral-300 md:text-lg lg:text-xl">
|
||||
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.
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-6 py-6 sm:flex-row">
|
||||
<Link
|
||||
href="https://discord.gg/ejRNvftDp9"
|
||||
className="w-48 gap-1 rounded-full border border-gray-200 bg-gradient-to-b from-gray-50 to-gray-100 px-5 py-3 text-center text-sm font-medium text-gray-800 shadow-sm dark:border-[#404040] dark:bg-gradient-to-b dark:from-[#5B5B5D] dark:to-[#262627] dark:text-white dark:shadow-inner dark:shadow-purple-500/10 flex items-center justify-center"
|
||||
>
|
||||
<IconBrandDiscord className="h-5 w-5 mr-2" />
|
||||
<span>Discord</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/MODSetter/SurfSense"
|
||||
className="w-48 gap-1 rounded-full border border-transparent bg-gray-800 px-5 py-3 text-center text-sm font-medium text-white shadow-sm hover:bg-gray-700 dark:bg-gradient-to-r dark:from-purple-700 dark:to-indigo-800 dark:text-white dark:hover:from-purple-600 dark:hover:to-indigo-700 flex items-center justify-center"
|
||||
>
|
||||
<IconBrandGithub className="h-5 w-5 mr-2" />
|
||||
<span>GitHub</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const TopLines = () => {
|
||||
return (
|
||||
<svg
|
||||
width="166"
|
||||
height="298"
|
||||
viewBox="0 0 166 298"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="aspect-square pointer-events-none absolute inset-x-0 top-0 h-[100px] w-full md:h-[200px]"
|
||||
>
|
||||
<title>Top Lines</title>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 1 -108)"
|
||||
stroke="url(#paint0_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 34 -108)"
|
||||
stroke="url(#paint1_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 67 -108)"
|
||||
stroke="url(#paint2_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 100 -108)"
|
||||
stroke="url(#paint3_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 133 -108)"
|
||||
stroke="url(#paint4_linear_254_143)"
|
||||
/>
|
||||
<line
|
||||
y1="-0.5"
|
||||
x2="406"
|
||||
y2="-0.5"
|
||||
transform="matrix(0 1 1 0 166 -108)"
|
||||
stroke="url(#paint5_linear_254_143)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint4_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint5_linear_254_143"
|
||||
x1="-7.42412e-06"
|
||||
y1="0.500009"
|
||||
x2="405"
|
||||
y2="0.500009"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const BottomLines = () => {
|
||||
return (
|
||||
<svg
|
||||
width="445"
|
||||
height="418"
|
||||
viewBox="0 0 445 418"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="aspect-square pointer-events-none absolute inset-x-0 -bottom-20 z-20 h-[150px] w-full md:h-[300px]"
|
||||
>
|
||||
<title>Bottom Lines</title>
|
||||
<line x1="139.5" y1="418" x2="139.5" y2="12" stroke="url(#paint0_linear_0_1)" />
|
||||
<line x1="172.5" y1="418" x2="172.5" y2="12" stroke="url(#paint1_linear_0_1)" />
|
||||
<line x1="205.5" y1="418" x2="205.5" y2="12" stroke="url(#paint2_linear_0_1)" />
|
||||
<line x1="238.5" y1="418" x2="238.5" y2="12" stroke="url(#paint3_linear_0_1)" />
|
||||
<line x1="271.5" y1="418" x2="271.5" y2="12" stroke="url(#paint4_linear_0_1)" />
|
||||
<line x1="304.5" y1="418" x2="304.5" y2="12" stroke="url(#paint5_linear_0_1)" />
|
||||
<path
|
||||
d="M1 149L109.028 235.894C112.804 238.931 115 243.515 115 248.361V417"
|
||||
stroke="url(#paint6_linear_0_1)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M444 149L335.972 235.894C332.196 238.931 330 243.515 330 248.361V417"
|
||||
stroke="url(#paint7_linear_0_1)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_0_1"
|
||||
x1="140.5"
|
||||
y1="418"
|
||||
x2="140.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_0_1"
|
||||
x1="173.5"
|
||||
y1="418"
|
||||
x2="173.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_0_1"
|
||||
x1="206.5"
|
||||
y1="418"
|
||||
x2="206.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_0_1"
|
||||
x1="239.5"
|
||||
y1="418"
|
||||
x2="239.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint4_linear_0_1"
|
||||
x1="272.5"
|
||||
y1="418"
|
||||
x2="272.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint5_linear_0_1"
|
||||
x1="305.5"
|
||||
y1="418"
|
||||
x2="305.5"
|
||||
y2="13"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="gray" className="dark:stop-color-white" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint6_linear_0_1"
|
||||
x1="115"
|
||||
y1="390.591"
|
||||
x2="-59.1703"
|
||||
y2="205.673"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop
|
||||
offset="1"
|
||||
stopColor="#E8E8E8"
|
||||
stopOpacity="0"
|
||||
className="dark:stop-color-[#F8F8F8]"
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint7_linear_0_1"
|
||||
x1="330"
|
||||
y1="390.591"
|
||||
x2="504.17"
|
||||
y2="205.673"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop
|
||||
offset="1"
|
||||
stopColor="#E8E8E8"
|
||||
stopOpacity="0"
|
||||
className="dark:stop-color-[#F8F8F8]"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const SideLines = () => {
|
||||
return (
|
||||
<svg
|
||||
width="1382"
|
||||
height="370"
|
||||
viewBox="0 0 1382 370"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="pointer-events-none absolute inset-0 z-30 h-full w-full"
|
||||
>
|
||||
<title>Side Lines</title>
|
||||
<path
|
||||
d="M268 115L181.106 6.97176C178.069 3.19599 173.485 1 168.639 1H0"
|
||||
stroke="url(#paint0_linear_337_46)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M1114 115L1200.89 6.97176C1203.93 3.19599 1208.52 1 1213.36 1H1382"
|
||||
stroke="url(#paint1_linear_337_46)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M268 255L181.106 363.028C178.069 366.804 173.485 369 168.639 369H0"
|
||||
stroke="url(#paint2_linear_337_46)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M1114 255L1200.89 363.028C1203.93 366.804 1208.52 369 1213.36 369H1382"
|
||||
stroke="url(#paint3_linear_337_46)"
|
||||
strokeOpacity="0.1"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_337_46"
|
||||
x1="26.4087"
|
||||
y1="1.00001"
|
||||
x2="211.327"
|
||||
y2="175.17"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop
|
||||
offset="1"
|
||||
stopColor="#E8E8E8"
|
||||
stopOpacity="0"
|
||||
className="dark:stop-color-[#F8F8F8]"
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_337_46"
|
||||
x1="1355.59"
|
||||
y1="1.00001"
|
||||
x2="1170.67"
|
||||
y2="175.17"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop
|
||||
offset="1"
|
||||
stopColor="#E8E8E8"
|
||||
stopOpacity="0"
|
||||
className="dark:stop-color-[#F8F8F8]"
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_337_46"
|
||||
x1="26.4087"
|
||||
y1="369"
|
||||
x2="211.327"
|
||||
y2="194.83"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop
|
||||
offset="1"
|
||||
stopColor="#E8E8E8"
|
||||
stopOpacity="0"
|
||||
className="dark:stop-color-[#F8F8F8]"
|
||||
/>
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_337_46"
|
||||
x1="1355.59"
|
||||
y1="369"
|
||||
x2="1170.67"
|
||||
y2="194.83"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0.481613" stopColor="#E8E8E8" className="dark:stop-color-[#F8F8F8]" />
|
||||
<stop
|
||||
offset="1"
|
||||
stopColor="#E8E8E8"
|
||||
stopOpacity="0"
|
||||
className="dark:stop-color-[#F8F8F8]"
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const BottomGradient = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="851"
|
||||
height="595"
|
||||
viewBox="0 0 851 595"
|
||||
fill="none"
|
||||
className={cn(
|
||||
"pointer-events-none absolute -right-80 bottom-0 h-full w-full opacity-30 dark:opacity-100 dark:hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<title>Bottom Gradient</title>
|
||||
<path
|
||||
d="M118.499 0H532.468L635.375 38.6161L665 194.625L562.093 346H0L24.9473 121.254L118.499 0Z"
|
||||
fill="url(#paint0_radial_254_132)"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_radial_254_132"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(412.5 346) rotate(-91.153) scale(397.581 423.744)"
|
||||
>
|
||||
<stop stopColor="#AAD3E9" />
|
||||
<stop offset="0.25" stopColor="#7FB8D4" />
|
||||
<stop offset="0.573634" stopColor="#5A9BB8" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const TopGradient = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="1007"
|
||||
height="997"
|
||||
viewBox="0 0 1007 997"
|
||||
fill="none"
|
||||
className={cn(
|
||||
"pointer-events-none absolute -left-96 top-0 h-full w-full opacity-30 dark:opacity-100 dark:hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<title>Top Gradient</title>
|
||||
<path
|
||||
d="M807 110.119L699.5 -117.546L8.5 -154L-141 246.994L-7 952L127 782.111L279 652.114L513 453.337L807 110.119Z"
|
||||
fill="url(#paint0_radial_254_135)"
|
||||
/>
|
||||
<path
|
||||
d="M807 110.119L699.5 -117.546L8.5 -154L-141 246.994L-7 952L127 782.111L279 652.114L513 453.337L807 110.119Z"
|
||||
fill="url(#paint1_radial_254_135)"
|
||||
/>
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="paint0_radial_254_135"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(77.0001 15.8894) rotate(90.3625) scale(869.41 413.353)"
|
||||
>
|
||||
<stop stopColor="#AAD3E9" />
|
||||
<stop offset="0.25" stopColor="#7FB8D4" />
|
||||
<stop offset="0.573634" stopColor="#5A9BB8" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
<radialGradient
|
||||
id="paint1_radial_254_135"
|
||||
cx="0"
|
||||
cy="0"
|
||||
r="1"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
gradientTransform="translate(127.5 -31) rotate(1.98106) scale(679.906 715.987)"
|
||||
>
|
||||
<stop stopColor="#AAD3E9" />
|
||||
<stop offset="0.283363" stopColor="#7FB8D4" />
|
||||
<stop offset="0.573634" stopColor="#5A9BB8" />
|
||||
<stop offset="1" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const DarkModeGradient = () => {
|
||||
return (
|
||||
<div className="hidden dark:block">
|
||||
<div className="absolute -left-48 -top-48 h-[800px] w-[800px] rounded-full bg-purple-900/20 blur-[180px]"></div>
|
||||
<div className="absolute -right-48 -bottom-48 h-[800px] w-[800px] rounded-full bg-indigo-900/20 blur-[180px]"></div>
|
||||
<div className="absolute left-1/2 top-1/2 h-[400px] w-[400px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-purple-800/10 blur-[120px]"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const { scrollY } = useScroll({
|
||||
target: ref,
|
||||
offset: ["start start", "end start"],
|
||||
});
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
|
||||
useMotionValueEvent(scrollY, "change", (latest) => {
|
||||
if (latest > 100) {
|
||||
setVisible(true);
|
||||
} else {
|
||||
setVisible(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div ref={ref} className="w-full fixed top-2 inset-x-0 z-50">
|
||||
<DesktopNav visible={visible} navItems={navItems} />
|
||||
<MobileNav visible={visible} navItems={navItems} />
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopNav = ({ navItems, visible }: NavbarProps) => {
|
||||
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to the login page
|
||||
window.location.href = "/login";
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
onMouseLeave={() => 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
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<motion.div
|
||||
className="lg:flex flex-row items-center justify-end space-x-1 text-sm"
|
||||
animate={{
|
||||
scale: visible ? 0.9 : 1,
|
||||
}}
|
||||
>
|
||||
{navItems.map((navItem, idx) => (
|
||||
<motion.div
|
||||
key={`nav-item-${navItem.name}`}
|
||||
onHoverStart={() => setHoveredIndex(idx)}
|
||||
className="relative"
|
||||
>
|
||||
<Link
|
||||
className="dark:text-white/90 text-gray-800 relative px-3 py-1.5 transition-colors"
|
||||
href={navItem.link}
|
||||
>
|
||||
<span className="relative z-10">{navItem.name}</span>
|
||||
{hoveredIndex === idx && (
|
||||
<motion.div
|
||||
layoutId="menu-hover"
|
||||
className="absolute inset-0 rounded-full dark:bg-gradient-to-r dark:from-white/10 dark:to-white/20 bg-gradient-to-r from-gray-200 to-gray-300"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1.1,
|
||||
background:
|
||||
"var(--tw-dark) ? radial-gradient(circle at center, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0.1) 50%, transparent 100%) : radial-gradient(circle at center, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.03) 50%, transparent 100%)",
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.8,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
bounce: 0.4,
|
||||
duration: 0.4,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
<ThemeTogglerComponent />
|
||||
<AnimatePresence mode="popLayout" initial={false}>
|
||||
{!visible && (
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 25,
|
||||
},
|
||||
}}
|
||||
exit={{
|
||||
scale: 0.8,
|
||||
opacity: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
className="hidden cursor-pointer md:flex items-center gap-2 rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
||||
>
|
||||
<IconUser className="h-4 w-4" />
|
||||
<span>Sign in</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileNav = ({ navItems, visible }: NavbarProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
// Redirect to the login page
|
||||
window.location.href = "./login";
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{
|
||||
backdropFilter: "blur(16px)",
|
||||
background: visible
|
||||
? "rgba(var(--background-rgb), 0.8)"
|
||||
: "rgba(var(--background-rgb), 0.6)",
|
||||
width: visible ? "80%" : "90%",
|
||||
y: visible ? 0 : 8,
|
||||
borderRadius: open ? "24px" : "full",
|
||||
padding: "8px 16px",
|
||||
}}
|
||||
initial={{
|
||||
width: "80%",
|
||||
background: "rgba(var(--background-rgb), 0.6)",
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className={cn(
|
||||
"flex relative flex-col lg:hidden w-full justify-between items-center max-w-[calc(100vw-2rem)] mx-auto z-50 backdrop-saturate-[1.8] rounded-full",
|
||||
visible ? "border border-solid dark:border-white/40 border-gray-300/30" : "border-0"
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--background-rgb": "var(--tw-dark) ? '0, 0, 0' : '255, 255, 255'",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row justify-between items-center w-full">
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeTogglerComponent />
|
||||
{open ? (
|
||||
<IconX className="dark:text-white/90 text-gray-800" onClick={() => setOpen(!open)} />
|
||||
) : (
|
||||
<IconMenu2
|
||||
className="dark:text-white/90 text-gray-800"
|
||||
onClick={() => setOpen(!open)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
}}
|
||||
className="flex rounded-3xl absolute top-16 dark:bg-black/80 bg-white/90 backdrop-blur-xl backdrop-saturate-[1.8] inset-x-0 z-50 flex-col items-start justify-start gap-4 w-full px-6 py-8"
|
||||
>
|
||||
{navItems.map((navItem: { link: string; name: string }) => (
|
||||
<Link
|
||||
key={`link-${navItem.name}`}
|
||||
href={navItem.link}
|
||||
onClick={() => setOpen(false)}
|
||||
className="relative dark:text-white/90 text-gray-800 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<motion.span className="block">{navItem.name}</motion.span>
|
||||
</Link>
|
||||
))}
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
className="flex cursor-pointer items-center gap-2 mt-4 w-full justify-center rounded-full dark:bg-white/20 dark:hover:bg-white/30 dark:text-white bg-gray-100 hover:bg-gray-200 text-gray-800 border-0"
|
||||
>
|
||||
<IconUser className="h-4 w-4" />
|
||||
<span>Sign in</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const isInView = useInView(ref);
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const [{ shouldShowHighlight, layoutStable }, dispatch] = useReducer(
|
||||
highlightReducer,
|
||||
initialState
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(null);
|
||||
const highlightedChunkRef = useRef<HTMLDivElement>(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 (
|
||||
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<SourceDetailSheet
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
chunkId={chunkId}
|
||||
sourceType={sourceType}
|
||||
title={node?.metadata?.title || node?.metadata?.group_name || "Source"}
|
||||
description={node?.text}
|
||||
url={node?.url}
|
||||
>
|
||||
<SheetTrigger asChild>
|
||||
<span className="text-[10px] font-bold bg-slate-500 hover:bg-slate-600 text-white rounded-full w-4 h-4 inline-flex items-center justify-center align-super cursor-pointer transition-colors">
|
||||
{index + 1}
|
||||
</span>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-full sm:max-w-5xl lg:max-w-7xl">
|
||||
<SheetHeader className="px-6 py-4 border-b">
|
||||
<SheetTitle className="flex items-center gap-3 text-lg">
|
||||
{getConnectorIcon(sourceType)}
|
||||
{document?.title || node?.metadata?.title || node?.metadata?.group_name || "Source"}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-base mt-2">
|
||||
{document
|
||||
? formatDocumentType(document.document_type)
|
||||
: sourceType && formatDocumentType(sourceType)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{!isDirectRenderSource && loading && (
|
||||
<div className="flex items-center justify-center h-64 px-6">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDirectRenderSource && error && (
|
||||
<div className="flex items-center justify-center h-64 px-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Direct render for TAVILY_API and LINEAR_API */}
|
||||
{isDirectRenderSource && (
|
||||
<ScrollArea className="h-[calc(100vh-10rem)]">
|
||||
<div className="px-6 py-4">
|
||||
{/* External Link */}
|
||||
{node?.url && (
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={(e) => handleUrlClick(e, node.url)}
|
||||
className="w-full py-3"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open in Browser
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Information */}
|
||||
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
|
||||
<h3 className="text-base font-semibold mb-4">Source Information</h3>
|
||||
<div className="text-sm text-muted-foreground mb-3 font-medium">
|
||||
{node?.metadata?.title || "Untitled"}
|
||||
</div>
|
||||
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{node?.text || "No content available"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* API-fetched document content */}
|
||||
{!isDirectRenderSource && document && (
|
||||
<ScrollArea className="h-[calc(100vh-10rem)]">
|
||||
<div className="px-6 py-4">
|
||||
{/* Document Metadata */}
|
||||
{document.document_metadata && Object.keys(document.document_metadata).length > 0 && (
|
||||
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
|
||||
<h3 className="text-base font-semibold mb-4">Document Information</h3>
|
||||
<dl className="grid grid-cols-1 gap-3 text-sm">
|
||||
{Object.entries(document.document_metadata).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-3">
|
||||
<dt className="font-medium text-muted-foreground capitalize min-w-0 flex-shrink-0">
|
||||
{key.replace(/_/g, " ")}:
|
||||
</dt>
|
||||
<dd className="text-foreground break-words">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Link */}
|
||||
{node?.url && (
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={(e) => handleUrlClick(e, node.url)}
|
||||
className="w-full py-3"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open in Browser
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chunks */}
|
||||
<div className="space-y-6" ref={chunksContainerRef}>
|
||||
<div className="mb-4">
|
||||
{/* Header row: header and button side by side */}
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<h3 className="text-base font-semibold mb-2 md:mb-0">Document Content</h3>
|
||||
{document.content && (
|
||||
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 py-2 px-3 font-medium border rounded-md bg-muted hover:bg-muted/80 transition-colors">
|
||||
<span>Summary</span>
|
||||
{summaryOpen ? (
|
||||
<ChevronUp className="h-4 w-4 transition-transform" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 transition-transform" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
{/* Expanded summary content: always full width, below the row */}
|
||||
{document.content && (
|
||||
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
|
||||
<CollapsibleContent className="pt-2 w-full">
|
||||
<div className="p-6 bg-muted/50 rounded-lg border">
|
||||
<MarkdownViewer content={document.content} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{document.chunks.map((chunk, idx) => (
|
||||
<div
|
||||
key={chunk.id}
|
||||
ref={chunk.id === chunkId ? highlightedChunkRef : null}
|
||||
className={cn(
|
||||
"p-6 rounded-lg border transition-all duration-300",
|
||||
chunk.id === chunkId
|
||||
? "bg-primary/10 border-primary shadow-md ring-1 ring-primary/20"
|
||||
: "bg-background border-border hover:bg-muted/50 hover:border-muted-foreground/20"
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Chunk {idx + 1} of {document.chunks.length}
|
||||
</span>
|
||||
{chunk.id === chunkId && (
|
||||
<span className="text-sm font-medium text-primary bg-primary/10 px-3 py-1 rounded-full">
|
||||
Referenced Chunk
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||
<MarkdownViewer content={chunk.content} className="max-w-fit" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</SourceDetailSheet>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Card className="border-muted hover:border-muted-foreground/20 transition-colors">
|
||||
<CardHeader className="pb-3 pt-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-sm font-medium leading-tight line-clamp-2">
|
||||
{source.title}
|
||||
</CardTitle>
|
||||
{hasUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 flex-shrink-0 hover:bg-muted"
|
||||
onClick={() => window.open(source.url, "_blank")}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 pb-3">
|
||||
<CardDescription className="text-xs line-clamp-3 leading-relaxed text-muted-foreground">
|
||||
{cleanDescription}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SourceDetailSheet
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
chunkId={chunkId}
|
||||
sourceType={sourceType}
|
||||
title={source.title}
|
||||
description={source.description}
|
||||
url={source.url}
|
||||
>
|
||||
<SheetTrigger asChild>
|
||||
<Card className="border-muted hover:border-muted-foreground/20 transition-colors cursor-pointer">
|
||||
<CardHeader className="pb-3 pt-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-sm font-medium leading-tight line-clamp-2 flex-1">
|
||||
{source.title}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Badge variant="secondary" className="text-[10px] h-5 px-2 font-mono">
|
||||
#{chunkId}
|
||||
</Badge>
|
||||
{hasUrl && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 flex-shrink-0 hover:bg-muted"
|
||||
onClick={(e) => handleUrlClick(e, source.url)}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0 pb-3">
|
||||
<CardDescription className="text-xs line-clamp-3 leading-relaxed text-muted-foreground">
|
||||
{cleanDescription}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</SheetTrigger>
|
||||
</SourceDetailSheet>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -126,6 +154,7 @@ export default function ChatSourcesDisplay({ message }: { message: Message }) {
|
|||
title: node.metadata.title,
|
||||
description: node.text,
|
||||
url: node.url || "",
|
||||
sourceType: sourceType,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
244
surfsense_web/components/chat/SourceDetailSheet.tsx
Normal file
244
surfsense_web/components/chat/SourceDetailSheet.tsx
Normal file
|
|
@ -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<HTMLDivElement>(null);
|
||||
const highlightedChunkRef = useRef<HTMLDivElement>(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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
{children}
|
||||
<SheetContent side="right" className="w-full sm:max-w-5xl lg:max-w-7xl">
|
||||
<SheetHeader className="px-6 py-4 border-b">
|
||||
<SheetTitle className="flex items-center gap-3 text-lg">
|
||||
{getConnectorIcon(sourceType)}
|
||||
{document?.title || title}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-base mt-2">
|
||||
{document
|
||||
? formatDocumentType(document.document_type)
|
||||
: sourceType && formatDocumentType(sourceType)}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{!isDirectRenderSource && loading && (
|
||||
<div className="flex items-center justify-center h-64 px-6">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isDirectRenderSource && error && (
|
||||
<div className="flex items-center justify-center h-64 px-6">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Direct render for TAVILY_API and LINKUP_API */}
|
||||
{isDirectRenderSource && (
|
||||
<ScrollArea className="h-[calc(100vh-10rem)]">
|
||||
<div className="px-6 py-4">
|
||||
{/* External Link */}
|
||||
{url && (
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={(e) => handleUrlClick(e, url)}
|
||||
className="w-full py-3"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open in Browser
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Source Information */}
|
||||
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
|
||||
<h3 className="text-base font-semibold mb-4">Source Information</h3>
|
||||
<div className="text-sm text-muted-foreground mb-3 font-medium">
|
||||
{title || "Untitled"}
|
||||
</div>
|
||||
<div className="text-sm text-foreground leading-relaxed whitespace-pre-wrap">
|
||||
{description || "No content available"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
{/* API-fetched document content */}
|
||||
{!isDirectRenderSource && document && (
|
||||
<ScrollArea className="h-[calc(100vh-10rem)]">
|
||||
<div className="px-6 py-4">
|
||||
{/* Document Metadata */}
|
||||
{document.document_metadata && Object.keys(document.document_metadata).length > 0 && (
|
||||
<div className="mb-8 p-6 bg-muted/50 rounded-lg border">
|
||||
<h3 className="text-base font-semibold mb-4">Document Information</h3>
|
||||
<dl className="grid grid-cols-1 gap-3 text-sm">
|
||||
{Object.entries(document.document_metadata).map(([key, value]) => (
|
||||
<div key={key} className="flex gap-3">
|
||||
<dt className="font-medium text-muted-foreground capitalize min-w-0 flex-shrink-0">
|
||||
{key.replace(/_/g, " ")}:
|
||||
</dt>
|
||||
<dd className="text-foreground break-words">{String(value)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* External Link */}
|
||||
{url && (
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
size="default"
|
||||
variant="outline"
|
||||
onClick={(e) => handleUrlClick(e, url)}
|
||||
className="w-full py-3"
|
||||
>
|
||||
<ExternalLink className="mr-2 h-4 w-4" />
|
||||
Open in Browser
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chunks */}
|
||||
<div className="space-y-6" ref={chunksContainerRef}>
|
||||
<div className="mb-4">
|
||||
{/* Header row: header and button side by side */}
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
<h3 className="text-base font-semibold mb-2 md:mb-0">Document Content</h3>
|
||||
{document.content && (
|
||||
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
|
||||
<CollapsibleTrigger className="flex items-center gap-2 py-2 px-3 font-medium border rounded-md bg-muted hover:bg-muted/80 transition-colors">
|
||||
<span>Summary</span>
|
||||
{summaryOpen ? (
|
||||
<ChevronUp className="h-4 w-4 transition-transform" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 transition-transform" />
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
{/* Expanded summary content: always full width, below the row */}
|
||||
{document.content && (
|
||||
<Collapsible open={summaryOpen} onOpenChange={setSummaryOpen}>
|
||||
<CollapsibleContent className="pt-2 w-full">
|
||||
<div className="p-6 bg-muted/50 rounded-lg border">
|
||||
<MarkdownViewer content={document.content} />
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{document.chunks.map((chunk, idx) => (
|
||||
<div
|
||||
key={chunk.id}
|
||||
ref={chunk.id === chunkId ? highlightedChunkRef : null}
|
||||
className={cn(
|
||||
"p-6 rounded-lg border transition-all duration-300",
|
||||
chunk.id === chunkId
|
||||
? "bg-primary/10 border-primary shadow-md ring-1 ring-primary/20"
|
||||
: "bg-background border-border hover:bg-muted/50 hover:border-muted-foreground/20"
|
||||
)}
|
||||
>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Chunk {idx + 1} of {document.chunks.length}
|
||||
</span>
|
||||
{chunk.id === chunkId && (
|
||||
<span className="text-sm font-medium text-primary bg-primary/10 px-3 py-1 rounded-full">
|
||||
Referenced Chunk
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||
<MarkdownViewer content={chunk.content} className="max-w-fit" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
358
surfsense_web/components/contact/contact-form.tsx
Normal file
358
surfsense_web/components/contact/contact-form.tsx
Normal file
|
|
@ -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<typeof contactFormSchema>;
|
||||
|
||||
export function ContactFormGridWithDetails() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
} = useForm<ContactFormData>({
|
||||
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 (
|
||||
<div className="mx-auto grid w-full max-w-7xl grid-cols-1 gap-10 px-4 py-10 md:px-6 md:py-20 lg:grid-cols-2">
|
||||
<div className="relative flex flex-col items-center overflow-hidden lg:items-start">
|
||||
<div className="flex items-start justify-start">
|
||||
<FeatureIconContainer className="flex items-center justify-center overflow-hidden">
|
||||
<IconMailFilled className="h-6 w-6 text-blue-500" />
|
||||
</FeatureIconContainer>
|
||||
</div>
|
||||
<h2 className="mt-9 bg-gradient-to-b from-neutral-800 to-neutral-900 bg-clip-text text-left text-xl font-bold text-transparent md:text-3xl lg:text-5xl dark:from-neutral-200 dark:to-neutral-300">
|
||||
Contact
|
||||
</h2>
|
||||
<p className="mt-8 max-w-lg text-center text-base text-neutral-600 md:text-left dark:text-neutral-400">
|
||||
We'd love to Hear From You.
|
||||
</p>
|
||||
|
||||
<div className="mt-10 hidden flex-col items-center gap-4 md:flex-row lg:flex">
|
||||
<Link
|
||||
href="mailto:rohan@surfsense.com"
|
||||
className="text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
rohan@surfsense.com
|
||||
</Link>
|
||||
<div className="h-1 w-1 rounded-full bg-neutral-500 dark:bg-neutral-400" />
|
||||
|
||||
<Link
|
||||
href="https://cal.com/mod-surfsense"
|
||||
className="text-sm text-neutral-500 dark:text-neutral-400"
|
||||
>
|
||||
https://cal.com/mod-surfsense
|
||||
</Link>
|
||||
</div>
|
||||
<div className="div relative mt-20 flex w-[600px] flex-shrink-0 -translate-x-10 items-center justify-center [perspective:800px] [transform-style:preserve-3d] sm:-translate-x-0 lg:-translate-x-32">
|
||||
<Pin className="h-30 w-85 top-0 left-0" />
|
||||
|
||||
<Image
|
||||
src="/contact/world.svg"
|
||||
width={500}
|
||||
height={500}
|
||||
alt="world map"
|
||||
className="[transform:rotateX(45deg)_translateZ(0px)] dark:invert dark:filter"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="relative mx-auto flex w-full max-w-2xl flex-col items-start gap-4 overflow-hidden rounded-3xl bg-gradient-to-b from-gray-100 to-gray-200 p-4 sm:p-10 dark:from-neutral-900 dark:to-neutral-950"
|
||||
>
|
||||
<Grid size={20} />
|
||||
<div className="relative z-20 mb-4 w-full">
|
||||
<label
|
||||
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
|
||||
htmlFor="name"
|
||||
>
|
||||
Full name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
{...register("name")}
|
||||
className={cn(
|
||||
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
|
||||
errors.name ? "border-red-500" : "border-transparent"
|
||||
)}
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-xs text-red-500">{errors.name.message}</p>}
|
||||
</div>
|
||||
<div className="relative z-20 mb-4 w-full">
|
||||
<label
|
||||
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
|
||||
htmlFor="email"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="john.doe@example.com"
|
||||
{...register("email")}
|
||||
className={cn(
|
||||
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
|
||||
errors.email ? "border-red-500" : "border-transparent"
|
||||
)}
|
||||
/>
|
||||
{errors.email && <p className="mt-1 text-xs text-red-500">{errors.email.message}</p>}
|
||||
</div>
|
||||
<div className="relative z-20 mb-4 w-full">
|
||||
<label
|
||||
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
|
||||
htmlFor="company"
|
||||
>
|
||||
Company
|
||||
</label>
|
||||
<input
|
||||
id="company"
|
||||
type="text"
|
||||
placeholder="Example Inc."
|
||||
{...register("company")}
|
||||
className={cn(
|
||||
"shadow-input h-10 w-full rounded-md border bg-white pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
|
||||
errors.company ? "border-red-500" : "border-transparent"
|
||||
)}
|
||||
/>
|
||||
{errors.company && <p className="mt-1 text-xs text-red-500">{errors.company.message}</p>}
|
||||
</div>
|
||||
<div className="relative z-20 mb-4 w-full">
|
||||
<label
|
||||
className="mb-2 inline-block text-sm font-medium text-neutral-600 dark:text-neutral-300"
|
||||
htmlFor="message"
|
||||
>
|
||||
Message <span className="text-neutral-400 text-xs font-normal">(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
rows={5}
|
||||
placeholder="Type your message here"
|
||||
{...register("message")}
|
||||
className={cn(
|
||||
"shadow-input w-full rounded-md border bg-white pt-4 pl-4 text-sm text-neutral-700 placeholder-neutral-500 outline-none focus:ring-2 focus:ring-neutral-800 focus:outline-none active:outline-none dark:border-neutral-800 dark:bg-neutral-800 dark:text-white",
|
||||
errors.message ? "border-red-500" : "border-transparent"
|
||||
)}
|
||||
/>
|
||||
{errors.message && <p className="mt-1 text-xs text-red-500">{errors.message.message}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="relative z-10 flex items-center justify-center rounded-md border border-transparent bg-neutral-800 px-4 py-2 text-sm font-medium text-white shadow-[0px_1px_0px_0px_#FFFFFF20_inset] transition duration-200 hover:bg-neutral-900 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm"
|
||||
>
|
||||
{isSubmitting ? "Submitting..." : "Submit"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Pin = ({ className }: { className?: string }) => {
|
||||
return (
|
||||
<motion.div
|
||||
style={{ transform: "translateZ(1px)" }}
|
||||
className={cn(
|
||||
"pointer-events-none absolute z-[60] flex h-40 w-96 items-center justify-center opacity-100 transition duration-500",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
<div className="absolute inset-x-0 top-0 z-20 mx-auto inline-block w-fit rounded-lg bg-neutral-200 px-2 py-1 text-xs font-normal text-neutral-700 dark:bg-neutral-800 dark:text-white">
|
||||
We are here
|
||||
<span className="absolute -bottom-0 left-[1.125rem] h-px w-[calc(100%-2.25rem)] bg-gradient-to-r from-blue-400/0 via-blue-400/90 to-blue-400/0 transition-opacity duration-500"></span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
perspective: "800px",
|
||||
transform: "rotateX(70deg) translateZ(0px)",
|
||||
}}
|
||||
className="absolute top-1/2 left-1/2 mt-4 ml-[0.09375rem] -translate-x-1/2 -translate-y-1/2"
|
||||
>
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{
|
||||
opacity: [0, 1, 0.5, 0],
|
||||
scale: 1,
|
||||
}}
|
||||
transition={{ duration: 6, repeat: Infinity, delay: 0 }}
|
||||
className="absolute top-1/2 left-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-sky-500/[0.08] shadow-[0_8px_16px_rgb(0_0_0/0.4)] dark:bg-sky-500/[0.2]"
|
||||
></motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{
|
||||
opacity: [0, 1, 0.5, 0],
|
||||
scale: 1,
|
||||
}}
|
||||
transition={{ duration: 6, repeat: Infinity, delay: 2 }}
|
||||
className="absolute top-1/2 left-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-sky-500/[0.08] shadow-[0_8px_16px_rgb(0_0_0/0.4)] dark:bg-sky-500/[0.2]"
|
||||
></motion.div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{
|
||||
opacity: [0, 1, 0.5, 0],
|
||||
scale: 1,
|
||||
}}
|
||||
transition={{ duration: 6, repeat: Infinity, delay: 4 }}
|
||||
className="absolute top-1/2 left-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 rounded-[50%] bg-sky-500/[0.08] shadow-[0_8px_16px_rgb(0_0_0/0.4)] dark:bg-sky-500/[0.2]"
|
||||
></motion.div>
|
||||
</>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<motion.div className="absolute right-1/2 bottom-1/2 h-20 w-px translate-y-[14px] bg-gradient-to-b from-transparent to-blue-500 blur-[2px]" />
|
||||
<motion.div className="absolute right-1/2 bottom-1/2 h-20 w-px translate-y-[14px] bg-gradient-to-b from-transparent to-blue-500" />
|
||||
<motion.div className="absolute right-1/2 bottom-1/2 z-40 h-[4px] w-[4px] translate-x-[1.5px] translate-y-[14px] rounded-full bg-blue-600 blur-[3px]" />
|
||||
<motion.div className="absolute right-1/2 bottom-1/2 z-40 h-[2px] w-[2px] translate-x-[0.5px] translate-y-[14px] rounded-full bg-blue-300" />
|
||||
</>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FeatureIconContainer = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-14 w-14 rounded-md bg-gradient-to-b from-gray-50 to-neutral-200 p-[4px] dark:from-neutral-800 dark:to-neutral-950",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-20 h-full w-full rounded-[5px] bg-gray-50 dark:bg-neutral-800",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
<div className="absolute inset-x-0 bottom-0 z-30 mx-auto h-4 w-full rounded-full bg-neutral-600 opacity-50 blur-lg"></div>
|
||||
<div className="absolute inset-x-0 bottom-0 mx-auto h-px w-[60%] bg-gradient-to-r from-transparent via-blue-500 to-transparent"></div>
|
||||
<div className="absolute inset-x-0 bottom-0 mx-auto h-px w-[60%] bg-gradient-to-r from-transparent via-blue-600 to-transparent dark:h-[8px] dark:blur-sm"></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Grid = ({ pattern, size }: { pattern?: number[][]; size?: number }) => {
|
||||
const p = pattern ?? [
|
||||
[9, 3],
|
||||
[8, 5],
|
||||
[10, 2],
|
||||
[7, 4],
|
||||
[9, 6],
|
||||
];
|
||||
return (
|
||||
<div className="pointer-events-none absolute top-0 left-1/2 -mt-2 -ml-20 h-full w-full [mask-image:linear-gradient(white,transparent)]">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-zinc-900/30 to-zinc-900/30 opacity-10 [mask-image:radial-gradient(farthest-side_at_top,white,transparent)] dark:from-zinc-900/30 dark:to-zinc-900/30">
|
||||
<GridPattern
|
||||
width={size ?? 20}
|
||||
height={size ?? 20}
|
||||
x="-12"
|
||||
y="4"
|
||||
squares={p}
|
||||
className="absolute inset-0 h-full w-full fill-black/100 stroke-black/100 mix-blend-overlay dark:fill-white/100 dark:stroke-white/100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function GridPattern({ width, height, x, y, squares, ...props }: any) {
|
||||
const patternId = useId();
|
||||
|
||||
return (
|
||||
<svg aria-hidden="true" {...props}>
|
||||
<defs>
|
||||
<pattern
|
||||
id={patternId}
|
||||
width={width}
|
||||
height={height}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={x}
|
||||
y={y}
|
||||
>
|
||||
<path d={`M.5 ${height}V.5H${width}`} fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" strokeWidth={0} fill={`url(#${patternId})`} />
|
||||
{squares && (
|
||||
<svg x={x} y={y} className="overflow-visible" aria-label="Grid squares">
|
||||
<title>Grid squares</title>
|
||||
{squares.map(([x, y]: any, idx: number) => (
|
||||
<rect
|
||||
strokeWidth="0"
|
||||
key={`${x}-${y}-${idx}`}
|
||||
width={width + 1}
|
||||
height={height + 1}
|
||||
x={x * width}
|
||||
y={y * height}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
110
surfsense_web/components/homepage/cta.tsx
Normal file
110
surfsense_web/components/homepage/cta.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
import { IconMessageCircleQuestion } from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function CTAHomepage() {
|
||||
return (
|
||||
<section className="w-full grid grid-cols-1 md:grid-cols-3 my-20 md:my-20 justify-start relative z-20 max-w-7xl mx-auto bg-gradient-to-br from-gray-100 to-white dark:from-neutral-900 dark:to-neutral-950">
|
||||
<GridLineHorizontal className="top-0" offset="200px" />
|
||||
<GridLineHorizontal className="bottom-0 top-auto" offset="200px" />
|
||||
<GridLineVertical className="left-0" offset="80px" />
|
||||
<GridLineVertical className="left-auto right-0" offset="80px" />
|
||||
<div className="md:col-span-2 p-8 md:p-14">
|
||||
<h2 className="text-left text-neutral-500 dark:text-neutral-200 text-xl md:text-3xl tracking-tight font-medium">
|
||||
Transform how your team{" "}
|
||||
<span className="font-bold text-black dark:text-white">discovers and collaborates</span>
|
||||
</h2>
|
||||
<p className="text-left text-neutral-500 mt-4 max-w-lg dark:text-neutral-200 text-xl md:text-3xl tracking-tight font-medium">
|
||||
Unite your <span className="text-sky-700">team's knowledge</span> in one collaborative
|
||||
space with <span className="text-indigo-700">intelligent search</span>.
|
||||
</p>
|
||||
|
||||
<div className="flex items-start sm:items-center flex-col sm:flex-row sm:gap-4">
|
||||
<Link href="/contact">
|
||||
<button
|
||||
type="button"
|
||||
className="mt-8 flex space-x-2 items-center group text-base px-4 py-2 rounded-lg text-black dark:text-white border border-neutral-200 dark:border-neutral-800 shadow-[0px_2px_0px_0px_rgba(255,255,255,0.3)_inset]"
|
||||
>
|
||||
<span>Talk to us</span>
|
||||
<IconMessageCircleQuestion className="text-black dark:text-white group-hover:translate-x-1 stroke-[1px] h-3 w-3 mt-0.5 transition-transform duration-200" />
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className="border-t md:border-t-0 md:border-l border-dashed p-8 md:p-14">
|
||||
<p className="text-base text-neutral-700 dark:text-neutral-200">
|
||||
"SurfSense has revolutionized how our team shares and discovers knowledge.
|
||||
Everyone can contribute and find what they need instantly. True collaboration at scale."
|
||||
</p>
|
||||
<div className="flex flex-col text-sm items-start mt-4 gap-1">
|
||||
<p className="font-bold text-neutral-800 dark:text-neutral-200">
|
||||
Sarah Chen
|
||||
</p>
|
||||
<p className="text-neutral-500 dark:text-neutral-400">
|
||||
Research Lead
|
||||
</p>
|
||||
</div>
|
||||
</div> */}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const GridLineHorizontal = ({ className, offset }: { className?: string; offset?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--background": "#ffffff",
|
||||
"--color": "rgba(0, 0, 0, 0.2)",
|
||||
"--height": "1px",
|
||||
"--width": "5px",
|
||||
"--fade-stop": "90%",
|
||||
"--offset": offset || "200px", //-100px if you want to keep the line inside
|
||||
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
||||
maskComposite: "exclude",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"absolute w-[calc(100%+var(--offset))] h-[var(--height)] left-[calc(var(--offset)/2*-1)]",
|
||||
"bg-[linear-gradient(to_right,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
||||
"[background-size:var(--width)_var(--height)]",
|
||||
"[mask:linear-gradient(to_left,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_right,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
|
||||
"[mask-composite:exclude]",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_right,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
|
||||
const GridLineVertical = ({ className, offset }: { className?: string; offset?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--background": "#ffffff",
|
||||
"--color": "rgba(0, 0, 0, 0.2)",
|
||||
"--height": "5px",
|
||||
"--width": "1px",
|
||||
"--fade-stop": "90%",
|
||||
"--offset": offset || "150px", //-100px if you want to keep the line inside
|
||||
"--color-dark": "rgba(255, 255, 255, 0.2)",
|
||||
maskComposite: "exclude",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"absolute h-[calc(100%+var(--offset))] w-[var(--width)] top-[calc(var(--offset)/2*-1)]",
|
||||
"bg-[linear-gradient(to_bottom,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
||||
"[background-size:var(--width)_var(--height)]",
|
||||
"[mask:linear-gradient(to_top,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_bottom,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
|
||||
"[mask-composite:exclude]",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
448
surfsense_web/components/homepage/features-bento-grid.tsx
Normal file
448
surfsense_web/components/homepage/features-bento-grid.tsx
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
import { IconMessage, IconMicrophone, IconSearch, IconUsers } from "@tabler/icons-react";
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
import { BentoGrid, BentoGridItem } from "@/components/ui/bento-grid";
|
||||
|
||||
export function FeaturesBentoGrid() {
|
||||
return (
|
||||
<BentoGrid className="max-w-7xl my-8 mx-auto md:auto-rows-[20rem]">
|
||||
{items.map((item, i) => (
|
||||
<BentoGridItem
|
||||
key={i}
|
||||
title={item.title}
|
||||
description={item.description}
|
||||
header={item.header}
|
||||
className={item.className}
|
||||
icon={item.icon}
|
||||
/>
|
||||
))}
|
||||
</BentoGrid>
|
||||
);
|
||||
}
|
||||
|
||||
const CitationIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-[6rem] items-center justify-center overflow-hidden rounded-xl bg-gradient-to-br from-blue-50 via-purple-50 to-pink-50 dark:from-blue-950/20 dark:via-purple-950/20 dark:to-pink-950/20 p-4">
|
||||
<svg viewBox="0 0 400 200" className="w-full h-full" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Citation feature illustration showing clickable source reference</title>
|
||||
{/* Background chat message */}
|
||||
<g>
|
||||
{/* Chat bubble */}
|
||||
<rect
|
||||
x="20"
|
||||
y="30"
|
||||
width="200"
|
||||
height="60"
|
||||
rx="12"
|
||||
className="fill-white dark:fill-neutral-800"
|
||||
opacity="0.9"
|
||||
/>
|
||||
{/* Text lines */}
|
||||
<line
|
||||
x1="35"
|
||||
y1="50"
|
||||
x2="150"
|
||||
y2="50"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-600"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="35"
|
||||
y1="65"
|
||||
x2="180"
|
||||
y2="65"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-600"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
|
||||
{/* Citation badge with glow */}
|
||||
<defs>
|
||||
<filter id="glow">
|
||||
<feGaussianBlur stdDeviation="2" result="coloredBlur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="coloredBlur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Clickable citation */}
|
||||
<g className="cursor-pointer" filter="url(#glow)">
|
||||
<rect
|
||||
x="185"
|
||||
y="57"
|
||||
width="28"
|
||||
height="20"
|
||||
rx="6"
|
||||
className="fill-blue-500 dark:fill-blue-600"
|
||||
/>
|
||||
<text
|
||||
x="199"
|
||||
y="70"
|
||||
fontSize="12"
|
||||
fontWeight="bold"
|
||||
className="fill-white"
|
||||
textAnchor="middle"
|
||||
>
|
||||
[1]
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{/* Connecting line with animation effect */}
|
||||
<g>
|
||||
<path
|
||||
d="M 199 77 Q 240 90, 260 110"
|
||||
className="stroke-blue-500 dark:stroke-blue-400"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="4,4"
|
||||
fill="none"
|
||||
opacity="0.6"
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
from="8"
|
||||
to="0"
|
||||
dur="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
|
||||
{/* Arrow head */}
|
||||
<polygon
|
||||
points="258,113 262,110 260,106"
|
||||
className="fill-blue-500 dark:fill-blue-400"
|
||||
opacity="0.6"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Citation popup card */}
|
||||
<g>
|
||||
{/* Card shadow */}
|
||||
<rect
|
||||
x="245"
|
||||
y="113"
|
||||
width="145"
|
||||
height="75"
|
||||
rx="10"
|
||||
className="fill-black"
|
||||
opacity="0.1"
|
||||
transform="translate(2, 2)"
|
||||
/>
|
||||
|
||||
{/* Main card */}
|
||||
<rect
|
||||
x="245"
|
||||
y="113"
|
||||
width="145"
|
||||
height="75"
|
||||
rx="10"
|
||||
className="fill-white dark:fill-neutral-800 stroke-blue-500 dark:stroke-blue-400"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
|
||||
{/* Card header */}
|
||||
<rect
|
||||
x="245"
|
||||
y="113"
|
||||
width="145"
|
||||
height="25"
|
||||
rx="10"
|
||||
className="fill-blue-100 dark:fill-blue-900/50"
|
||||
/>
|
||||
<line
|
||||
x1="245"
|
||||
y1="138"
|
||||
x2="390"
|
||||
y2="138"
|
||||
className="stroke-blue-200 dark:stroke-blue-800"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Header text */}
|
||||
<text
|
||||
x="317.5"
|
||||
y="128"
|
||||
fontSize="9"
|
||||
fontWeight="600"
|
||||
className="fill-blue-700 dark:fill-blue-300"
|
||||
textAnchor="middle"
|
||||
>
|
||||
Referenced Chunk
|
||||
</text>
|
||||
|
||||
{/* Content lines */}
|
||||
<line
|
||||
x1="255"
|
||||
y1="150"
|
||||
x2="365"
|
||||
y2="150"
|
||||
className="stroke-neutral-600 dark:stroke-neutral-400"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="255"
|
||||
y1="162"
|
||||
x2="340"
|
||||
y2="162"
|
||||
className="stroke-neutral-500 dark:stroke-neutral-500"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<line
|
||||
x1="255"
|
||||
y1="174"
|
||||
x2="380"
|
||||
y2="174"
|
||||
className="stroke-neutral-400 dark:stroke-neutral-600"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</g>
|
||||
|
||||
{/* Sparkle effects */}
|
||||
<g className="opacity-60">
|
||||
{/* Sparkle 1 */}
|
||||
<circle cx="195" cy="45" r="2" className="fill-yellow-400">
|
||||
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
<circle cx="195" cy="45" r="1" className="fill-white">
|
||||
<animate attributeName="opacity" values="0;1;0" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
{/* Sparkle 2 */}
|
||||
<circle cx="370" cy="125" r="2" className="fill-purple-400">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="2.5s"
|
||||
begin="0.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
<circle cx="370" cy="125" r="1" className="fill-white">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="2.5s"
|
||||
begin="0.5s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
|
||||
{/* Sparkle 3 */}
|
||||
<circle cx="250" cy="95" r="1.5" className="fill-blue-400">
|
||||
<animate
|
||||
attributeName="opacity"
|
||||
values="0;1;0"
|
||||
dur="3s"
|
||||
begin="1s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
{/* AI Sparkle icon in corner */}
|
||||
<g transform="translate(25, 100)">
|
||||
<path
|
||||
d="M 0,0 L 3,-8 L 6,0 L 14,3 L 6,6 L 3,14 L 0,6 L -8,3 Z"
|
||||
className="fill-purple-500 dark:fill-purple-400"
|
||||
opacity="0.7"
|
||||
>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
type="rotate"
|
||||
from="0 3 3"
|
||||
to="360 3 3"
|
||||
dur="8s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CollaborationIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-44 flex-1 flex-col items-center justify-center overflow-hidden pointer-events-none select-none">
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Illustration of a realtime collaboration in a text editor."
|
||||
className="pointer-events-none absolute inset-0 flex flex-col items-start justify-center pl-4 select-none"
|
||||
>
|
||||
<div className="relative flex h-fit w-fit flex-col items-start">
|
||||
<div className="w-full text-2xl sm:text-3xl lg:text-4xl leading-tight text-neutral-700 dark:text-neutral-300">
|
||||
<span className="flex items-stretch flex-wrap">
|
||||
{/* <span>Real-time </span> */}
|
||||
<span className="relative bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 px-1">
|
||||
Real-time
|
||||
</span>
|
||||
<span className="relative z-10 inline-flex items-stretch justify-start">
|
||||
<span className="absolute h-full w-0.5 rounded-b-sm bg-blue-500"></span>
|
||||
<span className="absolute inline-flex h-6 sm:h-7 -translate-y-full items-center rounded-t-sm rounded-r-sm px-2 py-0.5 text-xs sm:text-sm font-medium text-white bg-blue-500">
|
||||
Sarah
|
||||
</span>
|
||||
</span>
|
||||
<span>collabo</span>
|
||||
<span>orat</span>
|
||||
<span className="relative z-10 inline-flex items-stretch justify-start">
|
||||
<span className="absolute h-full w-0.5 rounded-b-sm bg-purple-600 dark:bg-purple-500"></span>
|
||||
<span className="absolute inline-flex h-6 sm:h-7 -translate-y-full items-center rounded-t-sm rounded-r-sm px-2 py-0.5 text-xs sm:text-sm font-medium text-white bg-purple-600 dark:bg-purple-500">
|
||||
Josh
|
||||
</span>
|
||||
</span>
|
||||
<span>ion</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bottom gradient fade */}
|
||||
<div className="absolute -right-4 bottom-0 -left-4 h-24 bg-gradient-to-t from-white dark:from-black to-transparent"></div>
|
||||
{/* Right gradient fade */}
|
||||
<div className="absolute top-0 -right-4 bottom-0 w-20 bg-gradient-to-l from-white dark:from-black to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AnnotationIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-44 flex-1 flex-col items-center justify-center overflow-hidden pointer-events-none select-none">
|
||||
<div
|
||||
role="img"
|
||||
aria-label="Illustration of a text editor with annotation comments."
|
||||
className="pointer-events-none absolute inset-0 flex flex-col items-start justify-center pl-4 select-none md:left-1/2"
|
||||
>
|
||||
<div className="relative flex h-fit w-fit flex-col items-start justify-center gap-3.5">
|
||||
{/* Text above the comment box */}
|
||||
<div className="absolute left-0 h-fit -translate-x-full pr-7 text-3xl sm:text-4xl lg:text-5xl tracking-tight whitespace-nowrap text-neutral-400 dark:text-neutral-600">
|
||||
<span className="relative">
|
||||
Add context with
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-white dark:from-black via-white dark:via-black to-transparent"></div>
|
||||
</span>{" "}
|
||||
<span className="bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300">
|
||||
comments
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Comment card */}
|
||||
<div className="flex flex-col items-start gap-4 rounded-xl bg-neutral-100 dark:bg-neutral-900/50 px-6 py-5 text-xl sm:text-2xl lg:text-3xl max-w-md">
|
||||
<div className="truncate leading-normal text-neutral-600 dark:text-neutral-400">
|
||||
<span>Let's discuss this tomorrow!</span>
|
||||
</div>
|
||||
|
||||
{/* Reaction icons */}
|
||||
<div className="flex items-center gap-3 opacity-30">
|
||||
{/* @ icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-6 h-6 sm:w-7 sm:h-7 lg:w-8 lg:h-8"
|
||||
>
|
||||
<title>Mention icon</title>
|
||||
<g
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.8"
|
||||
>
|
||||
<path d="M11.998 15.6a3.6 3.6 0 1 0 0-7.2 3.6 3.6 0 0 0 0 7.2Z" />
|
||||
<path d="M15.602 8.4v4.44c0 1.326 1.026 2.52 2.28 2.52a2.544 2.544 0 0 0 2.52-2.52V12a8.4 8.4 0 1 0-3.36 6.72" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Emoji icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="w-6 h-6 sm:w-7 sm:h-7 lg:w-8 lg:h-8"
|
||||
>
|
||||
<title>Emoji icon</title>
|
||||
<g
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.8"
|
||||
>
|
||||
<path d="M12.002 20.4a8.4 8.4 0 1 0 0-16.8 8.4 8.4 0 0 0 0 16.8Z" />
|
||||
<path d="M9 13.8s.9 1.8 3 1.8 3-1.8 3-1.8M9.6 9.6h.008M14.398 9.6h.009M9.597 9.9a.3.3 0 1 0 0-.6.3.3 0 0 0 0 .6ZM14.402 9.9a.3.3 0 1 0 0-.6.3.3 0 0 0 0 .6Z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Attachment icon */}
|
||||
<svg
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-6 h-6 sm:w-7 sm:h-7 lg:w-8 lg:h-8"
|
||||
>
|
||||
<title>Attachment icon</title>
|
||||
<path
|
||||
d="M16.8926 14.0829L12.425 18.4269C10.565 20.2353 7.47136 20.2353 5.61136 18.4269C3.75976 16.6269 3.75976 13.6029 5.61136 11.8029L12.4886 5.11529C13.7294 3.90809 15.7934 3.90689 17.0354 5.11169C18.2714 6.31169 18.2738 8.33009 17.039 9.53249L10.1462 16.2189C9.83929 16.5093 9.43285 16.6711 9.01036 16.6711C8.58786 16.6711 8.18142 16.5093 7.87456 16.2189C7.72623 16.0757 7.60817 15.9042 7.52737 15.7146C7.44656 15.525 7.40466 15.321 7.40416 15.1149C7.40416 14.7009 7.57216 14.3037 7.87456 14.0109L12.4178 9.59849"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom gradient fade */}
|
||||
<div className="absolute -right-4 bottom-0 -left-4 h-20 bg-gradient-to-t from-white dark:from-black to-transparent"></div>
|
||||
{/* Right gradient fade */}
|
||||
<div className="absolute top-0 -right-4 bottom-0 w-20 bg-gradient-to-l from-white dark:from-black to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AudioCommentIllustration = () => (
|
||||
<div className="relative flex w-full h-full min-h-[6rem] overflow-hidden rounded-xl">
|
||||
<Image
|
||||
src="/homepage/comments-audio.webp"
|
||||
alt="Audio Comment Illustration"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const items = [
|
||||
{
|
||||
title: "Find, Ask, Act",
|
||||
description:
|
||||
"Get instant information, detailed updates, and cited answers across company and personal knowledge.",
|
||||
header: <CitationIllustration />,
|
||||
className: "md:col-span-2",
|
||||
icon: <IconSearch className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "Work Together in Real Time",
|
||||
description:
|
||||
"Transform your company docs into multiplayer spaces with live edits, synced content, and presence.",
|
||||
header: <CollaborationIllustration />,
|
||||
className: "md:col-span-1",
|
||||
icon: <IconUsers className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "Collaborate Beyond Text",
|
||||
description:
|
||||
"Create podcasts and multimedia your team can comment on, share, and refine together.",
|
||||
header: <AudioCommentIllustration />,
|
||||
className: "md:col-span-1",
|
||||
icon: <IconMicrophone className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
{
|
||||
title: "Context Where It Counts",
|
||||
description: "Add comments directly to your chats and docs for clear, in-the-moment feedback.",
|
||||
header: <AnnotationIllustration />,
|
||||
className: "md:col-span-2",
|
||||
icon: <IconMessage className="h-4 w-4 text-neutral-500" />,
|
||||
},
|
||||
];
|
||||
84
surfsense_web/components/homepage/features-card.tsx
Normal file
84
surfsense_web/components/homepage/features-card.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { Sliders, Users, Workflow } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
|
||||
export function FeaturesCards() {
|
||||
return (
|
||||
<section className="py-2 md:py-8 dark:bg-transparent">
|
||||
<div className="@container mx-auto max-w-7xl">
|
||||
<div className="text-center">
|
||||
<h2 className="text-balance text-4xl font-semibold lg:text-5xl">
|
||||
Your Team's AI-Powered Knowledge Hub
|
||||
</h2>
|
||||
<p className="mt-4">
|
||||
Powerful features designed to enhance collaboration, boost productivity, and streamline
|
||||
your workflow.
|
||||
</p>
|
||||
</div>
|
||||
<div className="@min-4xl:max-w-full @min-4xl:grid-cols-3 mx-auto mt-8 grid max-w-sm gap-6 *:text-center md:mt-16">
|
||||
<Card className="group shadow-black-950/5">
|
||||
<CardHeader className="pb-3">
|
||||
<CardDecorator>
|
||||
<Workflow className="size-6" aria-hidden />
|
||||
</CardDecorator>
|
||||
|
||||
<h3 className="mt-6 font-medium">Streamlined Workflow</h3>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<p className="text-sm">
|
||||
Centralize all your knowledge and resources in one intelligent workspace. Find what
|
||||
you need instantly and accelerate decision-making.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="group shadow-black-950/5">
|
||||
<CardHeader className="pb-3">
|
||||
<CardDecorator>
|
||||
<Users className="size-6" aria-hidden />
|
||||
</CardDecorator>
|
||||
|
||||
<h3 className="mt-6 font-medium">Seamless Collaboration</h3>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<p className="text-sm">
|
||||
Work together effortlessly with real-time collaboration tools that keep your entire
|
||||
team aligned.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="group shadow-black-950/5">
|
||||
<CardHeader className="pb-3">
|
||||
<CardDecorator>
|
||||
<Sliders className="size-6" aria-hidden />
|
||||
</CardDecorator>
|
||||
|
||||
<h3 className="mt-6 font-medium">Fully Customizable</h3>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<p className="text-sm">
|
||||
Choose from 100+ leading LLMs and seamlessly call any model on demand.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const CardDecorator = ({ children }: { children: ReactNode }) => (
|
||||
<div
|
||||
aria-hidden
|
||||
className="relative mx-auto size-36 [mask-image:radial-gradient(ellipse_50%_50%_at_50%_50%,#000_70%,transparent_100%)]"
|
||||
>
|
||||
<div className="absolute inset-0 [--border:black] dark:[--border:white] bg-[linear-gradient(to_right,var(--border)_1px,transparent_1px),linear-gradient(to_bottom,var(--border)_1px,transparent_1px)] bg-[size:24px_24px] opacity-10" />
|
||||
<div className="bg-background absolute inset-0 m-auto flex size-12 items-center justify-center border-t border-l">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -22,7 +22,7 @@ export function Footer() {
|
|||
];
|
||||
|
||||
return (
|
||||
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 bg-white dark:bg-neutral-950 w-full relative overflow-hidden">
|
||||
<div className="border-t border-neutral-100 dark:border-white/[0.1] px-8 py-20 w-full relative overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto text-sm text-neutral-500 justify-between items-start md:px-8">
|
||||
<div className="flex flex-col items-center justify-center w-full relative">
|
||||
<div className="mr-0 md:mr-4 md:flex mb-4">
|
||||
319
surfsense_web/components/homepage/hero-section.tsx
Normal file
319
surfsense_web/components/homepage/hero-section.tsx
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
"use client";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import Balancer from "react-wrap-balancer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function HeroSection() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-20 md:px-8 md:py-40"
|
||||
>
|
||||
<BackgroundGrids />
|
||||
<CollisionMechanism
|
||||
beamOptions={{
|
||||
initialX: -400,
|
||||
translateX: 600,
|
||||
duration: 7,
|
||||
repeatDelay: 3,
|
||||
}}
|
||||
containerRef={containerRef}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
<CollisionMechanism
|
||||
beamOptions={{
|
||||
initialX: -200,
|
||||
translateX: 800,
|
||||
duration: 4,
|
||||
repeatDelay: 3,
|
||||
}}
|
||||
containerRef={containerRef}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
<CollisionMechanism
|
||||
beamOptions={{
|
||||
initialX: 200,
|
||||
translateX: 1200,
|
||||
duration: 5,
|
||||
repeatDelay: 3,
|
||||
}}
|
||||
containerRef={containerRef}
|
||||
parentRef={parentRef}
|
||||
/>
|
||||
<CollisionMechanism
|
||||
containerRef={containerRef}
|
||||
parentRef={parentRef}
|
||||
beamOptions={{
|
||||
initialX: 400,
|
||||
translateX: 1400,
|
||||
duration: 6,
|
||||
repeatDelay: 3,
|
||||
}}
|
||||
/>
|
||||
|
||||
<h2 className="relative z-50 mx-auto mb-4 mt-4 max-w-4xl text-balance text-center text-3xl font-semibold tracking-tight text-gray-700 md:text-7xl dark:text-neutral-300">
|
||||
<Balancer>
|
||||
The AI Workspace{" "}
|
||||
<div className="relative mx-auto inline-block w-max [filter:drop-shadow(0px_1px_3px_rgba(27,_37,_80,_0.14))]">
|
||||
<div className="text-black [text-shadow:0_0_rgba(0,0,0,0.1)] dark:text-white">
|
||||
<span className="">Built for Teams</span>
|
||||
</div>
|
||||
</div>
|
||||
</Balancer>
|
||||
</h2>
|
||||
{/* // TODO:aCTUAL DESCRITION */}
|
||||
<p className="relative z-50 mx-auto mt-4 max-w-lg px-4 text-center text-base/6 text-gray-600 dark:text-gray-200">
|
||||
Connect any LLM to your internal knowledge sources and chat with it in real time alongside
|
||||
your team.
|
||||
</p>
|
||||
<div className="mb-10 mt-8 flex w-full flex-col items-center justify-center gap-4 px-8 sm:flex-row md:mb-20">
|
||||
<Link
|
||||
href="/contact"
|
||||
className="group relative z-20 flex h-10 w-full cursor-pointer items-center justify-center space-x-2 rounded-lg bg-black p-px px-4 py-2 text-center text-sm font-semibold leading-6 text-white no-underline transition duration-200 sm:w-52 dark:bg-white dark:text-black"
|
||||
>
|
||||
Start Free Trial
|
||||
</Link>
|
||||
<Link
|
||||
href="/pricing"
|
||||
className="shadow-input group relative z-20 flex h-10 w-full cursor-pointer items-center justify-center space-x-2 rounded-lg bg-white p-px px-4 py-2 text-sm font-semibold leading-6 text-black no-underline transition duration-200 hover:-translate-y-0.5 sm:w-52 dark:bg-neutral-800 dark:text-white"
|
||||
>
|
||||
Explore
|
||||
</Link>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative mx-auto max-w-7xl rounded-[32px] border border-neutral-200/50 bg-neutral-100 p-2 backdrop-blur-lg md:p-4 dark:border-neutral-700 dark:bg-neutral-800/50"
|
||||
>
|
||||
<div className="rounded-[24px] border border-neutral-200 bg-white p-2 dark:border-neutral-700 dark:bg-black">
|
||||
{/* Light mode image */}
|
||||
<Image
|
||||
src="/homepage/temp_hero_light.png"
|
||||
alt="header"
|
||||
width={1920}
|
||||
height={1080}
|
||||
className="rounded-[20px] block dark:hidden"
|
||||
/>
|
||||
{/* Dark mode image */}
|
||||
<Image
|
||||
src="/homepage/temp_hero_dark.png"
|
||||
alt="header"
|
||||
width={1920}
|
||||
height={1080}
|
||||
className="rounded-[20px] hidden dark:block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BackgroundGrids = () => {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0 z-0 grid h-full w-full -rotate-45 transform select-none grid-cols-2 gap-10 md:grid-cols-4">
|
||||
<div className="relative h-full w-full">
|
||||
<GridLineVertical className="left-0" />
|
||||
<GridLineVertical className="left-auto right-0" />
|
||||
</div>
|
||||
<div className="relative h-full w-full">
|
||||
<GridLineVertical className="left-0" />
|
||||
<GridLineVertical className="left-auto right-0" />
|
||||
</div>
|
||||
<div className="relative h-full w-full bg-gradient-to-b from-transparent via-neutral-100 to-transparent dark:via-neutral-800">
|
||||
<GridLineVertical className="left-0" />
|
||||
<GridLineVertical className="left-auto right-0" />
|
||||
</div>
|
||||
<div className="relative h-full w-full">
|
||||
<GridLineVertical className="left-0" />
|
||||
<GridLineVertical className="left-auto right-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CollisionMechanism = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
parentRef: React.RefObject<HTMLDivElement | null>;
|
||||
beamOptions?: {
|
||||
initialX?: number;
|
||||
translateX?: number;
|
||||
initialY?: number;
|
||||
translateY?: number;
|
||||
rotate?: number;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
repeatDelay?: number;
|
||||
};
|
||||
}
|
||||
>(({ parentRef, containerRef, beamOptions = {} }, ref) => {
|
||||
const beamRef = useRef<HTMLDivElement>(null);
|
||||
const [collision, setCollision] = useState<{
|
||||
detected: boolean;
|
||||
coordinates: { x: number; y: number } | null;
|
||||
}>({ detected: false, coordinates: null });
|
||||
const [beamKey, setBeamKey] = useState(0);
|
||||
const [cycleCollisionDetected, setCycleCollisionDetected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkCollision = () => {
|
||||
if (beamRef.current && containerRef.current && parentRef.current && !cycleCollisionDetected) {
|
||||
const beamRect = beamRef.current.getBoundingClientRect();
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
const parentRect = parentRef.current.getBoundingClientRect();
|
||||
|
||||
if (beamRect.bottom >= containerRect.top) {
|
||||
const relativeX = beamRect.left - parentRect.left + beamRect.width / 2;
|
||||
const relativeY = beamRect.bottom - parentRect.top;
|
||||
|
||||
setCollision({
|
||||
detected: true,
|
||||
coordinates: { x: relativeX, y: relativeY },
|
||||
});
|
||||
setCycleCollisionDetected(true);
|
||||
if (beamRef.current) {
|
||||
beamRef.current.style.opacity = "0";
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const animationInterval = setInterval(checkCollision, 50);
|
||||
|
||||
return () => clearInterval(animationInterval);
|
||||
}, [cycleCollisionDetected, containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (collision.detected && collision.coordinates) {
|
||||
setTimeout(() => {
|
||||
setCollision({ detected: false, coordinates: null });
|
||||
setCycleCollisionDetected(false);
|
||||
// Set beam opacity to 0
|
||||
if (beamRef.current) {
|
||||
beamRef.current.style.opacity = "1";
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// Reset the beam animation after a delay
|
||||
setTimeout(() => {
|
||||
setBeamKey((prevKey) => prevKey + 1);
|
||||
}, 2000);
|
||||
}
|
||||
}, [collision]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
key={beamKey}
|
||||
ref={beamRef}
|
||||
animate="animate"
|
||||
initial={{
|
||||
translateY: beamOptions.initialY || "-200px",
|
||||
translateX: beamOptions.initialX || "0px",
|
||||
rotate: beamOptions.rotate || -45,
|
||||
}}
|
||||
variants={{
|
||||
animate: {
|
||||
translateY: beamOptions.translateY || "800px",
|
||||
translateX: beamOptions.translateX || "700px",
|
||||
rotate: beamOptions.rotate || -45,
|
||||
},
|
||||
}}
|
||||
transition={{
|
||||
duration: beamOptions.duration || 8,
|
||||
repeat: Infinity,
|
||||
repeatType: "loop",
|
||||
ease: "linear",
|
||||
delay: beamOptions.delay || 0,
|
||||
repeatDelay: beamOptions.repeatDelay || 0,
|
||||
}}
|
||||
className={cn(
|
||||
"absolute left-96 top-20 m-auto h-14 w-px rounded-full bg-gradient-to-t from-orange-500 via-yellow-500 to-transparent",
|
||||
beamOptions.className
|
||||
)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{collision.detected && collision.coordinates && (
|
||||
<Explosion
|
||||
key={`${collision.coordinates.x}-${collision.coordinates.y}`}
|
||||
className=""
|
||||
style={{
|
||||
left: `${collision.coordinates.x + 20}px`,
|
||||
top: `${collision.coordinates.y}px`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
CollisionMechanism.displayName = "CollisionMechanism";
|
||||
|
||||
const Explosion = ({ ...props }: React.HTMLProps<HTMLDivElement>) => {
|
||||
const spans = Array.from({ length: 20 }, (_, index) => ({
|
||||
id: index,
|
||||
initialX: 0,
|
||||
initialY: 0,
|
||||
directionX: Math.floor(Math.random() * 80 - 40),
|
||||
directionY: Math.floor(Math.random() * -50 - 10),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div {...props} className={cn("absolute z-50 h-2 w-2", props.className)}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: [0, 1, 0] }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
className="absolute -inset-x-10 top-0 m-auto h-[4px] w-10 rounded-full bg-gradient-to-r from-transparent via-orange-500 to-transparent blur-sm"
|
||||
></motion.div>
|
||||
{spans.map((span) => (
|
||||
<motion.span
|
||||
key={span.id}
|
||||
initial={{ x: span.initialX, y: span.initialY, opacity: 1 }}
|
||||
animate={{ x: span.directionX, y: span.directionY, opacity: 0 }}
|
||||
transition={{ duration: Math.random() * 1.5 + 0.5, ease: "easeOut" }}
|
||||
className="absolute h-1 w-1 rounded-full bg-gradient-to-b from-orange-500 to-yellow-500"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const GridLineVertical = ({ className, offset }: { className?: string; offset?: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--background": "#ffffff",
|
||||
"--color": "rgba(0, 0, 0, 0.2)",
|
||||
"--height": "5px",
|
||||
"--width": "1px",
|
||||
"--fade-stop": "90%",
|
||||
"--offset": offset || "150px", //-100px if you want to keep the line inside
|
||||
"--color-dark": "rgba(255, 255, 255, 0.3)",
|
||||
maskComposite: "exclude",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"absolute top-[calc(var(--offset)/2*-1)] h-[calc(100%+var(--offset))] w-[var(--width)]",
|
||||
"bg-[linear-gradient(to_bottom,var(--color),var(--color)_50%,transparent_0,transparent)]",
|
||||
"[background-size:var(--width)_var(--height)]",
|
||||
"[mask:linear-gradient(to_top,var(--background)_var(--fade-stop),transparent),_linear-gradient(to_bottom,var(--background)_var(--fade-stop),transparent),_linear-gradient(black,black)]",
|
||||
"[mask-composite:exclude]",
|
||||
"z-30",
|
||||
"dark:bg-[linear-gradient(to_bottom,var(--color-dark),var(--color-dark)_50%,transparent_0,transparent)]",
|
||||
className
|
||||
)}
|
||||
></div>
|
||||
);
|
||||
};
|
||||
185
surfsense_web/components/homepage/integrations.tsx
Normal file
185
surfsense_web/components/homepage/integrations.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
interface Integration {
|
||||
name: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const INTEGRATIONS: Integration[] = [
|
||||
// Search
|
||||
{ name: "Tavily", icon: "https://www.tavily.com/images/logo.svg" },
|
||||
{
|
||||
name: "LinkUp",
|
||||
icon: "https://framerusercontent.com/images/7zeIm6t3f1HaSltkw8upEvsD80.png?scale-down-to=512",
|
||||
},
|
||||
|
||||
// Communication
|
||||
{ name: "Slack", icon: "https://cdn.simpleicons.org/slack/4A154B" },
|
||||
{ name: "Discord", icon: "https://cdn.simpleicons.org/discord/5865F2" },
|
||||
{ name: "Gmail", icon: "https://cdn.simpleicons.org/gmail/EA4335" },
|
||||
|
||||
// Project Management
|
||||
{ name: "Linear", icon: "https://cdn.simpleicons.org/linear/5E6AD2" },
|
||||
{ name: "Jira", icon: "https://cdn.simpleicons.org/jira/0052CC" },
|
||||
{ name: "ClickUp", icon: "https://cdn.simpleicons.org/clickup/7B68EE" },
|
||||
{ name: "Airtable", icon: "https://cdn.simpleicons.org/airtable/18BFFF" },
|
||||
|
||||
// Documentation & Knowledge
|
||||
{ name: "Confluence", icon: "https://cdn.simpleicons.org/confluence/172B4D" },
|
||||
{ name: "Notion", icon: "https://cdn.simpleicons.org/notion/000000/ffffff" },
|
||||
|
||||
// Cloud Storage
|
||||
{ name: "Google Drive", icon: "https://cdn.simpleicons.org/googledrive/4285F4" },
|
||||
{ name: "Dropbox", icon: "https://cdn.simpleicons.org/dropbox/0061FF" },
|
||||
{
|
||||
name: "Amazon S3",
|
||||
icon: "https://upload.wikimedia.org/wikipedia/commons/b/bc/Amazon-S3-Logo.svg",
|
||||
},
|
||||
|
||||
// Development
|
||||
{ name: "GitHub", icon: "https://cdn.simpleicons.org/github/181717/ffffff" },
|
||||
|
||||
// Productivity
|
||||
{ name: "Google Calendar", icon: "https://cdn.simpleicons.org/googlecalendar/4285F4" },
|
||||
{ name: "Luma", icon: "https://images.lumacdn.com/social-images/default-social-202407.png" },
|
||||
|
||||
// Media
|
||||
{ name: "YouTube", icon: "https://cdn.simpleicons.org/youtube/FF0000" },
|
||||
];
|
||||
|
||||
function SemiCircleOrbit({ radius, centerX, centerY, count, iconSize, startIndex }: any) {
|
||||
return (
|
||||
<>
|
||||
{/* Semi-circle glow background */}
|
||||
<div className="absolute inset-0 flex justify-center items-start overflow-visible">
|
||||
<div
|
||||
className="
|
||||
w-[800px] h-[800px] rounded-full
|
||||
bg-[radial-gradient(circle_at_center,rgba(0,0,0,0.15),transparent_70%)]
|
||||
dark:bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.15),transparent_70%)]
|
||||
blur-3xl
|
||||
pointer-events-none
|
||||
"
|
||||
style={{
|
||||
zIndex: 0,
|
||||
transform: "translateY(-20%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Orbit icons */}
|
||||
{Array.from({ length: count }).map((_, index) => {
|
||||
const actualIndex = startIndex + index;
|
||||
// Skip if we've run out of integrations
|
||||
if (actualIndex >= INTEGRATIONS.length) return null;
|
||||
|
||||
const angle = (index / (count - 1)) * 180;
|
||||
const x = radius * Math.cos((angle * Math.PI) / 180);
|
||||
const y = radius * Math.sin((angle * Math.PI) / 180);
|
||||
const integration = INTEGRATIONS[actualIndex];
|
||||
|
||||
// Tooltip positioning — above or below based on angle
|
||||
const tooltipAbove = angle > 90;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="absolute flex flex-col items-center group"
|
||||
style={{
|
||||
left: `${centerX + x - iconSize / 2}px`,
|
||||
top: `${centerY - y - iconSize / 2}px`,
|
||||
zIndex: 5,
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={integration.icon}
|
||||
alt={integration.name}
|
||||
width={iconSize}
|
||||
height={iconSize}
|
||||
className="object-contain cursor-pointer transition-transform hover:scale-110"
|
||||
style={{ minWidth: iconSize, minHeight: iconSize }} // fix accidental shrink
|
||||
/>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div
|
||||
className={`absolute ${
|
||||
tooltipAbove ? "bottom-[calc(100%+8px)]" : "top-[calc(100%+8px)]"
|
||||
} hidden group-hover:block w-auto min-w-max rounded-lg bg-black px-3 py-1.5 text-xs text-white shadow-lg text-center whitespace-nowrap`}
|
||||
>
|
||||
{integration.name}
|
||||
<div
|
||||
className={`absolute left-1/2 -translate-x-1/2 w-3 h-3 rotate-45 bg-black ${
|
||||
tooltipAbove ? "top-full" : "bottom-full"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExternalIntegrations() {
|
||||
const [size, setSize] = useState({ width: 0, height: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const updateSize = () => setSize({ width: window.innerWidth, height: window.innerHeight });
|
||||
updateSize();
|
||||
window.addEventListener("resize", updateSize);
|
||||
return () => window.removeEventListener("resize", updateSize);
|
||||
}, []);
|
||||
|
||||
const baseWidth = Math.min(size.width * 0.8, 700);
|
||||
const centerX = baseWidth / 2;
|
||||
const centerY = baseWidth * 0.5;
|
||||
|
||||
const iconSize =
|
||||
size.width < 480
|
||||
? Math.max(24, baseWidth * 0.05)
|
||||
: size.width < 768
|
||||
? Math.max(28, baseWidth * 0.06)
|
||||
: Math.max(32, baseWidth * 0.07);
|
||||
|
||||
return (
|
||||
<section className="py-12 relative min-h-screen w-full overflow-visible">
|
||||
<div className="relative flex flex-col items-center text-center z-10">
|
||||
<h1 className="my-6 text-4xl font-bold lg:text-7xl">Integrations</h1>
|
||||
<p className="mb-12 max-w-2xl text-gray-600 dark:text-gray-400 lg:text-xl">
|
||||
Integrate with your team's most important tools
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="relative overflow-visible"
|
||||
style={{ width: baseWidth, height: baseWidth * 0.7, paddingBottom: "100px" }}
|
||||
>
|
||||
<SemiCircleOrbit
|
||||
radius={baseWidth * 0.22}
|
||||
centerX={centerX}
|
||||
centerY={centerY}
|
||||
count={5}
|
||||
iconSize={iconSize}
|
||||
startIndex={0}
|
||||
/>
|
||||
<SemiCircleOrbit
|
||||
radius={baseWidth * 0.36}
|
||||
centerX={centerX}
|
||||
centerY={centerY}
|
||||
count={6}
|
||||
iconSize={iconSize}
|
||||
startIndex={5}
|
||||
/>
|
||||
<SemiCircleOrbit
|
||||
radius={baseWidth * 0.5}
|
||||
centerX={centerX}
|
||||
centerY={centerY}
|
||||
count={8}
|
||||
iconSize={iconSize}
|
||||
startIndex={11}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
180
surfsense_web/components/homepage/navbar.tsx
Normal file
180
surfsense_web/components/homepage/navbar.tsx
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"use client";
|
||||
import { IconBrandDiscord, IconBrandGithub, IconMenu2, IconX } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { ThemeTogglerComponent } from "@/components/theme/theme-toggle";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const Navbar = () => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
|
||||
const navItems = [
|
||||
{ name: "Home", link: "/" },
|
||||
{ name: "Pricing", link: "/pricing" },
|
||||
{ name: "Sign In", link: "/login" },
|
||||
{ name: "Docs", link: "/docs" },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 20);
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="fixed top-1 left-0 right-0 z-[60] w-full">
|
||||
<DesktopNav navItems={navItems} isScrolled={isScrolled} />
|
||||
<MobileNav navItems={navItems} isScrolled={isScrolled} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DesktopNav = ({ navItems, isScrolled }: any) => {
|
||||
const [hovered, setHovered] = useState<number | null>(null);
|
||||
return (
|
||||
<motion.div
|
||||
onMouseLeave={() => {
|
||||
setHovered(null);
|
||||
}}
|
||||
className={cn(
|
||||
"mx-auto hidden w-full max-w-7xl flex-row items-center justify-between self-start rounded-full px-4 py-2 lg:flex transition-all duration-300",
|
||||
isScrolled
|
||||
? "bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50"
|
||||
: "bg-transparent border border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
||||
</div>
|
||||
<div className="hidden flex-1 flex-row items-center justify-center space-x-2 text-sm font-medium text-zinc-600 transition duration-200 hover:text-zinc-800 lg:flex lg:space-x-2">
|
||||
{navItems.map((navItem: any, idx: number) => (
|
||||
<Link
|
||||
onMouseEnter={() => setHovered(idx)}
|
||||
className="relative px-4 py-2 text-neutral-600 dark:text-neutral-300"
|
||||
key={`link=${idx}`}
|
||||
href={navItem.link}
|
||||
>
|
||||
{hovered === idx && (
|
||||
<motion.div
|
||||
layoutId="hovered"
|
||||
className="absolute inset-0 h-full w-full rounded-full bg-gray-100 dark:bg-neutral-800"
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-20">{navItem.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="https://discord.gg/ejRNvftDp9"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden rounded-full p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center justify-center"
|
||||
>
|
||||
<IconBrandDiscord className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/MODSetter/SurfSense"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden rounded-full px-3 py-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors md:flex items-center gap-1.5"
|
||||
>
|
||||
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-300">8.3k</span>
|
||||
</Link>
|
||||
<ThemeTogglerComponent />
|
||||
<Link
|
||||
href="/contact"
|
||||
className="hidden rounded-full bg-black px-8 py-2 text-sm font-bold text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] md:block dark:bg-white dark:text-black"
|
||||
>
|
||||
Book a call
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileNav = ({ navItems, isScrolled }: any) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
animate={{ borderRadius: open ? "4px" : "2rem" }}
|
||||
key={String(open)}
|
||||
className={cn(
|
||||
"mx-auto flex w-full max-w-[calc(100vw-2rem)] flex-col items-center justify-between px-4 py-2 lg:hidden transition-all duration-300",
|
||||
isScrolled
|
||||
? "bg-white/80 backdrop-blur-md border border-white/20 shadow-lg dark:bg-neutral-950/80 dark:border-neutral-800/50"
|
||||
: "bg-transparent border border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Logo className="h-8 w-8 rounded-md" />
|
||||
<span className="dark:text-white/90 text-gray-800 text-lg font-bold">SurfSense</span>
|
||||
</div>
|
||||
{open ? (
|
||||
<IconX className="text-black dark:text-white" onClick={() => setOpen(!open)} />
|
||||
) : (
|
||||
<IconMenu2 className="text-black dark:text-white" onClick={() => setOpen(!open)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="absolute inset-x-0 top-16 z-20 flex w-full flex-col items-start justify-start gap-4 rounded-lg bg-white/80 backdrop-blur-md border border-white/20 shadow-lg px-4 py-8 dark:bg-neutral-950/80 dark:border-neutral-800/50"
|
||||
>
|
||||
{navItems.map((navItem: any, idx: number) => (
|
||||
<Link
|
||||
key={`link=${idx}`}
|
||||
href={navItem.link}
|
||||
className="relative text-neutral-600 dark:text-neutral-300"
|
||||
>
|
||||
<motion.span className="block">{navItem.name} </motion.span>
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex w-full items-center gap-2 pt-2">
|
||||
<Link
|
||||
href="https://discord.gg/your-server"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center rounded-lg p-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<IconBrandDiscord className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/MODSetter/SurfSense"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-2 hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<IconBrandGithub className="h-5 w-5 text-neutral-600 dark:text-neutral-300" />
|
||||
<span className="text-sm font-medium text-neutral-600 dark:text-neutral-300">
|
||||
8.3k
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded-lg bg-black px-8 py-2 font-medium text-white shadow-[0px_-2px_0px_0px_rgba(255,255,255,0.4)_inset] dark:bg-white dark:text-black"
|
||||
>
|
||||
Book a call
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { AlertCircle, Bot, Plus, Trash2 } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
|
@ -17,29 +17,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
||||
import { type CreateLLMConfig, useLLMConfigs } from "@/hooks/use-llm-configs";
|
||||
|
||||
const LLM_PROVIDERS = [
|
||||
{ value: "OPENAI", label: "OpenAI", example: "gpt-4o, gpt-4, gpt-3.5-turbo" },
|
||||
{
|
||||
value: "ANTHROPIC",
|
||||
label: "Anthropic",
|
||||
example: "claude-3-5-sonnet-20241022, claude-3-opus-20240229",
|
||||
},
|
||||
{ value: "GROQ", label: "Groq", example: "llama3-70b-8192, mixtral-8x7b-32768" },
|
||||
{ value: "COHERE", label: "Cohere", example: "command-r-plus, command-r" },
|
||||
{ value: "HUGGINGFACE", label: "HuggingFace", example: "microsoft/DialoGPT-medium" },
|
||||
{ value: "AZURE_OPENAI", label: "Azure OpenAI", example: "gpt-4, gpt-35-turbo" },
|
||||
{ value: "GOOGLE", label: "Google", example: "gemini-pro, gemini-pro-vision" },
|
||||
{ value: "AWS_BEDROCK", label: "AWS Bedrock", example: "anthropic.claude-v2" },
|
||||
{ value: "OLLAMA", label: "Ollama", example: "llama2, codellama" },
|
||||
{ value: "MISTRAL", label: "Mistral", example: "mistral-large-latest, mistral-medium" },
|
||||
{ value: "TOGETHER_AI", label: "Together AI", example: "togethercomputer/llama-2-70b-chat" },
|
||||
{ value: "REPLICATE", label: "Replicate", example: "meta/llama-2-70b-chat" },
|
||||
{ value: "OPENROUTER", label: "OpenRouter", example: "anthropic/claude-opus-4.1, openai/gpt-5" },
|
||||
{ value: "CUSTOM", label: "Custom Provider", example: "your-custom-model" },
|
||||
];
|
||||
|
||||
interface AddProviderStepProps {
|
||||
onConfigCreated?: () => void;
|
||||
onConfigDeleted?: () => void;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { AlertCircle, Bot, Brain, CheckCircle, Zap } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ArrowRight, Bot, Brain, CheckCircle, Sparkles, Zap } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useLLMConfigs, useLLMPreferences } from "@/hooks/use-llm-configs";
|
||||
|
|
|
|||
222
surfsense_web/components/pricing.tsx
Normal file
222
surfsense_web/components/pricing.tsx
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
"use client";
|
||||
|
||||
import NumberFlow from "@number-flow/react";
|
||||
import confetti from "canvas-confetti";
|
||||
import { Check, Star } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PricingPlan {
|
||||
name: string;
|
||||
price: string;
|
||||
yearlyPrice: string;
|
||||
period: string;
|
||||
features: string[];
|
||||
description: string;
|
||||
buttonText: string;
|
||||
href: string;
|
||||
isPopular: boolean;
|
||||
}
|
||||
|
||||
interface PricingProps {
|
||||
plans: PricingPlan[];
|
||||
title?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function Pricing({
|
||||
plans,
|
||||
title = "Simple, Transparent Pricing",
|
||||
description = "Choose the plan that works for you\nAll plans include access to our platform, lead generation tools, and dedicated support.",
|
||||
}: PricingProps) {
|
||||
const [isMonthly, setIsMonthly] = useState(true);
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
const switchRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const handleToggle = (checked: boolean) => {
|
||||
setIsMonthly(!checked);
|
||||
if (checked && switchRef.current) {
|
||||
const rect = switchRef.current.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
|
||||
confetti({
|
||||
particleCount: 50,
|
||||
spread: 60,
|
||||
origin: {
|
||||
x: x / window.innerWidth,
|
||||
y: y / window.innerHeight,
|
||||
},
|
||||
colors: [
|
||||
"hsl(var(--primary))",
|
||||
"hsl(var(--accent))",
|
||||
"hsl(var(--secondary))",
|
||||
"hsl(var(--muted))",
|
||||
],
|
||||
ticks: 200,
|
||||
gravity: 1.2,
|
||||
decay: 0.94,
|
||||
startVelocity: 30,
|
||||
shapes: ["circle"],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container py-20">
|
||||
<div className="text-center space-y-4 mb-12">
|
||||
<h2 className="text-4xl font-bold tracking-tight sm:text-5xl">{title}</h2>
|
||||
<p className="text-muted-foreground text-lg whitespace-pre-line">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center mb-10">
|
||||
<label
|
||||
className="relative inline-flex items-center cursor-pointer"
|
||||
htmlFor="billing-toggle"
|
||||
>
|
||||
<Label>
|
||||
<Switch
|
||||
ref={switchRef as any}
|
||||
checked={!isMonthly}
|
||||
onCheckedChange={handleToggle}
|
||||
className="relative"
|
||||
/>
|
||||
</Label>
|
||||
</label>
|
||||
<span className="ml-2 font-semibold">
|
||||
Annual billing <span className="text-primary">(Save 20%)</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-4",
|
||||
plans.length === 2 ? "md:grid-cols-2 max-w-5xl mx-auto" : "md:grid-cols-3"
|
||||
)}
|
||||
>
|
||||
{plans.map((plan, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ y: 50, opacity: 1 }}
|
||||
whileInView={
|
||||
isDesktop
|
||||
? plans.length === 2
|
||||
? {
|
||||
y: plan.isPopular ? -20 : 0,
|
||||
opacity: 1,
|
||||
scale: plan.isPopular ? 1.0 : 0.96,
|
||||
}
|
||||
: {
|
||||
y: plan.isPopular ? -20 : 0,
|
||||
opacity: 1,
|
||||
x: index === 2 ? -30 : index === 0 ? 30 : 0,
|
||||
scale: index === 0 || index === 2 ? 0.94 : 1.0,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
viewport={{ once: true }}
|
||||
transition={{
|
||||
duration: 1.6,
|
||||
type: "spring",
|
||||
stiffness: 100,
|
||||
damping: 30,
|
||||
delay: 0.4,
|
||||
opacity: { duration: 0.5 },
|
||||
}}
|
||||
className={cn(
|
||||
`rounded-2xl border-[1px] p-6 bg-background text-center lg:flex lg:flex-col lg:justify-center relative`,
|
||||
plan.isPopular ? "border-primary border-2" : "border-border",
|
||||
"flex flex-col",
|
||||
!plan.isPopular && "mt-5",
|
||||
plans.length === 3 && (index === 0 || index === 2)
|
||||
? "z-0 transform translate-x-0 translate-y-0 -translate-z-[50px] rotate-y-[10deg]"
|
||||
: plans.length === 2 && !plan.isPopular
|
||||
? "z-0"
|
||||
: "z-10",
|
||||
plans.length === 3 && index === 0 && "origin-right",
|
||||
plans.length === 3 && index === 2 && "origin-left"
|
||||
)}
|
||||
>
|
||||
{plan.isPopular && (
|
||||
<div className="absolute top-0 right-0 bg-primary py-0.5 px-2 rounded-bl-xl rounded-tr-xl flex items-center">
|
||||
<Star className="text-primary-foreground h-4 w-4 fill-current" />
|
||||
<span className="text-primary-foreground ml-1 font-sans font-semibold">
|
||||
Popular
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 flex flex-col">
|
||||
<p className="text-base font-semibold text-muted-foreground">{plan.name}</p>
|
||||
<div className="mt-6 flex items-center justify-center gap-x-2">
|
||||
<span className="text-5xl font-bold tracking-tight text-foreground">
|
||||
{isNaN(Number(plan.price)) ? (
|
||||
<span>{isMonthly ? plan.price : plan.yearlyPrice}</span>
|
||||
) : (
|
||||
<NumberFlow
|
||||
value={isMonthly ? Number(plan.price) : Number(plan.yearlyPrice)}
|
||||
format={{
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}}
|
||||
transformTiming={{
|
||||
duration: 500,
|
||||
easing: "ease-out",
|
||||
}}
|
||||
willChange
|
||||
className="font-variant-numeric: tabular-nums"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{plan.period && plan.period !== "Next 3 months" && (
|
||||
<span className="text-sm font-semibold leading-6 tracking-wide text-muted-foreground">
|
||||
/ {plan.period}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
{isNaN(Number(plan.price)) ? "" : isMonthly ? "billed monthly" : "billed annually"}
|
||||
</p>
|
||||
|
||||
<ul className="mt-5 gap-2 flex flex-col">
|
||||
{plan.features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
<Check className="h-4 w-4 text-primary mt-1 flex-shrink-0" />
|
||||
<span className="text-left">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<hr className="w-full my-4" />
|
||||
|
||||
<Link
|
||||
href={plan.href}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: "outline",
|
||||
}),
|
||||
"group relative w-full gap-2 overflow-hidden text-lg font-semibold tracking-tighter",
|
||||
"transform-gpu ring-offset-current transition-all duration-300 ease-out hover:ring-2 hover:ring-primary hover:ring-offset-1 hover:bg-primary hover:text-primary-foreground",
|
||||
plan.isPopular
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-background text-foreground"
|
||||
)}
|
||||
>
|
||||
{plan.buttonText}
|
||||
</Link>
|
||||
<p className="mt-6 text-xs leading-5 text-muted-foreground">{plan.description}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
surfsense_web/components/pricing/pricing-section.tsx
Normal file
53
surfsense_web/components/pricing/pricing-section.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import { Pricing } from "@/components/pricing";
|
||||
|
||||
const demoPlans = [
|
||||
{
|
||||
name: "COMMUNITY",
|
||||
price: "0",
|
||||
yearlyPrice: "0",
|
||||
period: "forever",
|
||||
features: [
|
||||
"Supports 100+ LLMs",
|
||||
"Supports local Ollama or vLLM setups",
|
||||
"6000+ Embedding Models",
|
||||
"50+ File extensions supported.",
|
||||
"Podcasts support with local TTS providers.",
|
||||
"Connects with 15+ external sources.",
|
||||
"Cross-Browser Extension for dynamic webpages including authenticated content",
|
||||
"Upcoming: Mergeable MindMaps",
|
||||
"Upcoming: Note Management",
|
||||
],
|
||||
description: "Open source version with powerful features",
|
||||
buttonText: "Get Started",
|
||||
href: "/docs",
|
||||
isPopular: true,
|
||||
},
|
||||
{
|
||||
name: "ENTERPRISE",
|
||||
price: "Contact Us",
|
||||
yearlyPrice: "Contact Us",
|
||||
period: "",
|
||||
features: [
|
||||
"Everything in Community",
|
||||
"Priority Support",
|
||||
"Access Controls",
|
||||
"Collaboration and multiplayer features",
|
||||
"Video generation",
|
||||
"Advanced security features",
|
||||
],
|
||||
description: "For large organizations with specific needs",
|
||||
buttonText: "Contact Sales",
|
||||
href: "/contact",
|
||||
isPopular: false,
|
||||
},
|
||||
];
|
||||
|
||||
function PricingBasic() {
|
||||
return (
|
||||
<Pricing plans={demoPlans} title="SurfSense Pricing" description="Choose that works for you" />
|
||||
);
|
||||
}
|
||||
|
||||
export default PricingBasic;
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { MoveLeftIcon, Plus, Search, Trash2 } from "lucide-react";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
|
|
@ -13,6 +12,7 @@ import {
|
|||
Settings2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
|
|
@ -15,6 +14,7 @@ import {
|
|||
Settings2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
|
|
@ -37,95 +37,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
||||
import { type CreateLLMConfig, type LLMConfig, useLLMConfigs } from "@/hooks/use-llm-configs";
|
||||
|
||||
const LLM_PROVIDERS = [
|
||||
{
|
||||
value: "OPENAI",
|
||||
label: "OpenAI",
|
||||
example: "gpt-4o, gpt-4, gpt-3.5-turbo",
|
||||
description: "Most popular and versatile AI models",
|
||||
},
|
||||
{
|
||||
value: "ANTHROPIC",
|
||||
label: "Anthropic",
|
||||
example: "claude-3-5-sonnet-20241022, claude-3-opus-20240229",
|
||||
description: "Constitutional AI with strong reasoning",
|
||||
},
|
||||
{
|
||||
value: "GROQ",
|
||||
label: "Groq",
|
||||
example: "llama3-70b-8192, mixtral-8x7b-32768",
|
||||
description: "Ultra-fast inference speeds",
|
||||
},
|
||||
{
|
||||
value: "COHERE",
|
||||
label: "Cohere",
|
||||
example: "command-r-plus, command-r",
|
||||
description: "Enterprise-focused language models",
|
||||
},
|
||||
{
|
||||
value: "HUGGINGFACE",
|
||||
label: "HuggingFace",
|
||||
example: "microsoft/DialoGPT-medium",
|
||||
description: "Open source model hub",
|
||||
},
|
||||
{
|
||||
value: "AZURE_OPENAI",
|
||||
label: "Azure OpenAI",
|
||||
example: "gpt-4, gpt-35-turbo",
|
||||
description: "Enterprise OpenAI through Azure",
|
||||
},
|
||||
{
|
||||
value: "GOOGLE",
|
||||
label: "Google",
|
||||
example: "gemini-pro, gemini-pro-vision",
|
||||
description: "Google's Gemini AI models",
|
||||
},
|
||||
{
|
||||
value: "AWS_BEDROCK",
|
||||
label: "AWS Bedrock",
|
||||
example: "anthropic.claude-v2",
|
||||
description: "AWS managed AI service",
|
||||
},
|
||||
{
|
||||
value: "OLLAMA",
|
||||
label: "Ollama",
|
||||
example: "llama2, codellama",
|
||||
description: "Run models locally",
|
||||
},
|
||||
{
|
||||
value: "MISTRAL",
|
||||
label: "Mistral",
|
||||
example: "mistral-large-latest, mistral-medium",
|
||||
description: "European AI excellence",
|
||||
},
|
||||
{
|
||||
value: "TOGETHER_AI",
|
||||
label: "Together AI",
|
||||
example: "togethercomputer/llama-2-70b-chat",
|
||||
description: "Decentralized AI platform",
|
||||
},
|
||||
{
|
||||
value: "REPLICATE",
|
||||
label: "Replicate",
|
||||
example: "meta/llama-2-70b-chat",
|
||||
description: "Run models via API",
|
||||
},
|
||||
{
|
||||
value: "OPENROUTER",
|
||||
label: "OpenRouter",
|
||||
example: "anthropic/claude-opus-4.1, openai/gpt-5",
|
||||
description: "API gateway and LLM marketplace that provides unified access ",
|
||||
},
|
||||
{
|
||||
value: "CUSTOM",
|
||||
label: "Custom Provider",
|
||||
example: "your-custom-model",
|
||||
description: "Your own model endpoint",
|
||||
},
|
||||
];
|
||||
|
||||
export function ModelConfigManager() {
|
||||
const {
|
||||
llmConfigs,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { MoonIcon, SunIcon } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
|
|||
54
surfsense_web/components/ui/bento-grid.tsx
Normal file
54
surfsense_web/components/ui/bento-grid.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const BentoGrid = ({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto grid max-w-7xl grid-cols-1 gap-4 md:auto-rows-[18rem] md:grid-cols-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BentoGridItem = ({
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
header,
|
||||
icon,
|
||||
}: {
|
||||
className?: string;
|
||||
title?: string | React.ReactNode;
|
||||
description?: string | React.ReactNode;
|
||||
header?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/bento shadow-input row-span-1 flex flex-col justify-between space-y-4 rounded-xl border border-neutral-200 bg-white p-4 transition duration-200 hover:shadow-xl dark:border-white/[0.2] dark:bg-black dark:shadow-none",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{header}
|
||||
<div className="transition duration-200 group-hover/bento:translate-x-2">
|
||||
{icon}
|
||||
<div className="mt-2 mb-2 font-sans font-bold text-neutral-600 dark:text-neutral-200">
|
||||
{title}
|
||||
</div>
|
||||
<div className="font-sans text-xs font-normal text-neutral-600 dark:text-neutral-300">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import { motion, type SpringOptions, useSpring, useTransform } from "framer-motion";
|
||||
import { motion, type SpringOptions, useSpring, useTransform } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
|
|||
29
surfsense_web/components/ui/switch.tsx
Normal file
29
surfsense_web/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
|
|
@ -8,7 +8,7 @@ import {
|
|||
useMotionValue,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "framer-motion";
|
||||
} from "motion/react";
|
||||
import type React from "react";
|
||||
import { useRef } from "react";
|
||||
|
||||
|
|
|
|||
99
surfsense_web/contracts/enums/llm-providers.ts
Normal file
99
surfsense_web/contracts/enums/llm-providers.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
export interface LLMProvider {
|
||||
value: string;
|
||||
label: string;
|
||||
example: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const LLM_PROVIDERS: LLMProvider[] = [
|
||||
{
|
||||
value: "OPENAI",
|
||||
label: "OpenAI",
|
||||
example: "gpt-4o, gpt-4, gpt-3.5-turbo",
|
||||
description: "Industry-leading GPT models with broad capabilities",
|
||||
},
|
||||
{
|
||||
value: "ANTHROPIC",
|
||||
label: "Anthropic",
|
||||
example: "claude-3-5-sonnet-20241022, claude-3-opus-20240229",
|
||||
description: "Claude models with strong reasoning and long context windows",
|
||||
},
|
||||
{
|
||||
value: "GROQ",
|
||||
label: "Groq",
|
||||
example: "llama3-70b-8192, mixtral-8x7b-32768",
|
||||
description: "Lightning-fast inference with custom LPU hardware",
|
||||
},
|
||||
{
|
||||
value: "COHERE",
|
||||
label: "Cohere",
|
||||
example: "command-r-plus, command-r",
|
||||
description: "Enterprise NLP models optimized for business applications",
|
||||
},
|
||||
{
|
||||
value: "HUGGINGFACE",
|
||||
label: "HuggingFace",
|
||||
example: "microsoft/DialoGPT-medium",
|
||||
description: "Access thousands of open-source models",
|
||||
},
|
||||
{
|
||||
value: "AZURE_OPENAI",
|
||||
label: "Azure OpenAI",
|
||||
example: "gpt-4, gpt-35-turbo",
|
||||
description: "OpenAI models with Microsoft Azure enterprise features",
|
||||
},
|
||||
{
|
||||
value: "GOOGLE",
|
||||
label: "Google",
|
||||
example: "gemini-pro, gemini-pro-vision",
|
||||
description: "Gemini models with multimodal capabilities",
|
||||
},
|
||||
{
|
||||
value: "AWS_BEDROCK",
|
||||
label: "AWS Bedrock",
|
||||
example: "anthropic.claude-v2",
|
||||
description: "Fully managed foundation models on AWS infrastructure",
|
||||
},
|
||||
{
|
||||
value: "OLLAMA",
|
||||
label: "Ollama",
|
||||
example: "llama2, codellama",
|
||||
description: "Run open-source models locally on your machine",
|
||||
},
|
||||
{
|
||||
value: "MISTRAL",
|
||||
label: "Mistral",
|
||||
example: "mistral-large-latest, mistral-medium",
|
||||
description: "High-performance open-source models from Europe",
|
||||
},
|
||||
{
|
||||
value: "TOGETHER_AI",
|
||||
label: "Together AI",
|
||||
example: "togethercomputer/llama-2-70b-chat",
|
||||
description: "Scalable cloud platform for open-source models",
|
||||
},
|
||||
{
|
||||
value: "REPLICATE",
|
||||
label: "Replicate",
|
||||
example: "meta/llama-2-70b-chat",
|
||||
description: "Cloud API for running machine learning models",
|
||||
},
|
||||
{
|
||||
value: "OPENROUTER",
|
||||
label: "OpenRouter",
|
||||
example: "anthropic/claude-opus-4.1, openai/gpt-5",
|
||||
description: "Unified API gateway for multiple LLM providers",
|
||||
},
|
||||
{
|
||||
value: "COMETAPI",
|
||||
label: "CometAPI",
|
||||
example: "gpt-5-mini, claude-sonnet-4-5",
|
||||
description: "Access 500+ AI models through one unified API",
|
||||
},
|
||||
{
|
||||
value: "CUSTOM",
|
||||
label: "Custom Provider",
|
||||
example: "your-custom-model",
|
||||
description: "Connect to your own custom model endpoint",
|
||||
},
|
||||
];
|
||||
11
surfsense_web/drizzle.config.ts
Normal file
11
surfsense_web/drizzle.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import "dotenv/config";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./drizzle",
|
||||
schema: "./app/db/schema.ts",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL!,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
export * from "./use-document-by-chunk";
|
||||
export * from "./use-logs";
|
||||
export * from "./useSearchSourceConnectors";
|
||||
export * from "./use-search-source-connectors";
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
import {
|
||||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/useSearchSourceConnectors";
|
||||
} from "@/hooks/use-search-source-connectors";
|
||||
|
||||
export function useConnectorEditPage(connectorId: number, searchSpaceId: string) {
|
||||
const router = useRouter();
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { normalizeListResponse } from "@/lib/pagination";
|
||||
|
||||
export interface Document {
|
||||
id: number;
|
||||
|
|
@ -30,43 +31,76 @@ export type DocumentType =
|
|||
| "AIRTABLE_CONNECTOR"
|
||||
| "LUMA_CONNECTOR";
|
||||
|
||||
export function useDocuments(searchSpaceId: number, lazy: boolean = false) {
|
||||
export interface UseDocumentsOptions {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
lazy?: boolean;
|
||||
}
|
||||
|
||||
export function useDocuments(searchSpaceId: number, options?: UseDocumentsOptions | boolean) {
|
||||
// Support both old boolean API and new options API for backward compatibility
|
||||
const opts = typeof options === "boolean" ? { lazy: options } : options || {};
|
||||
const { page, pageSize = 300, lazy = false } = opts;
|
||||
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(!lazy); // Don't show loading initially for lazy mode
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoaded, setIsLoaded] = useState(false); // Memoization flag
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
if (isLoaded && lazy) return; // Avoid redundant calls in lazy mode
|
||||
const fetchDocuments = useCallback(
|
||||
async (fetchPage?: number, fetchPageSize?: number) => {
|
||||
if (isLoaded && lazy) return; // Avoid redundant calls in lazy mode
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents?search_space_id=${searchSpaceId}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Build query params
|
||||
const params = new URLSearchParams({
|
||||
search_space_id: searchSpaceId.toString(),
|
||||
});
|
||||
|
||||
// Use passed parameters or fall back to state/options
|
||||
const effectivePage = fetchPage !== undefined ? fetchPage : page;
|
||||
const effectivePageSize = fetchPageSize !== undefined ? fetchPageSize : pageSize;
|
||||
|
||||
if (effectivePage !== undefined) {
|
||||
params.append("page", effectivePage.toString());
|
||||
}
|
||||
if (effectivePageSize !== undefined) {
|
||||
params.append("page_size", effectivePageSize.toString());
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to fetch documents");
|
||||
throw new Error("Failed to fetch documents");
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to fetch documents");
|
||||
throw new Error("Failed to fetch documents");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const normalized = normalizeListResponse<Document>(data);
|
||||
setDocuments(normalized.items);
|
||||
setTotal(normalized.total);
|
||||
setError(null);
|
||||
setIsLoaded(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch documents");
|
||||
console.error("Error fetching documents:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setDocuments(data);
|
||||
setError(null);
|
||||
setIsLoaded(true);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to fetch documents");
|
||||
console.error("Error fetching documents:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [searchSpaceId, isLoaded, lazy]);
|
||||
},
|
||||
[searchSpaceId, page, pageSize, isLoaded, lazy]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lazy && searchSpaceId) {
|
||||
|
|
@ -80,6 +114,64 @@ export function useDocuments(searchSpaceId: number, lazy: boolean = false) {
|
|||
await fetchDocuments();
|
||||
}, [fetchDocuments]);
|
||||
|
||||
// Function to search documents by title
|
||||
const searchDocuments = useCallback(
|
||||
async (searchQuery: string, fetchPage?: number, fetchPageSize?: number) => {
|
||||
if (!searchQuery.trim()) {
|
||||
// If search is empty, fetch all documents
|
||||
return fetchDocuments(fetchPage, fetchPageSize);
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Build query params
|
||||
const params = new URLSearchParams({
|
||||
search_space_id: searchSpaceId.toString(),
|
||||
title: searchQuery,
|
||||
});
|
||||
|
||||
// Use passed parameters or fall back to state/options
|
||||
const effectivePage = fetchPage !== undefined ? fetchPage : page;
|
||||
const effectivePageSize = fetchPageSize !== undefined ? fetchPageSize : pageSize;
|
||||
|
||||
if (effectivePage !== undefined) {
|
||||
params.append("page", effectivePage.toString());
|
||||
}
|
||||
if (effectivePageSize !== undefined) {
|
||||
params.append("page_size", effectivePageSize.toString());
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/search/?${params.toString()}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
|
||||
},
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error("Failed to search documents");
|
||||
throw new Error("Failed to search documents");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const normalized = normalizeListResponse<Document>(data);
|
||||
setDocuments(normalized.items);
|
||||
setTotal(normalized.total);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to search documents");
|
||||
console.error("Error searching documents:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[searchSpaceId, page, pageSize, fetchDocuments]
|
||||
);
|
||||
|
||||
// Function to delete a document
|
||||
const deleteDocument = useCallback(
|
||||
async (documentId: number) => {
|
||||
|
|
@ -114,10 +206,12 @@ export function useDocuments(searchSpaceId: number, lazy: boolean = false) {
|
|||
|
||||
return {
|
||||
documents,
|
||||
total,
|
||||
loading,
|
||||
error,
|
||||
isLoaded,
|
||||
fetchDocuments, // Manual fetch function for lazy mode
|
||||
searchDocuments, // Search function
|
||||
refreshDocuments,
|
||||
deleteDocument,
|
||||
};
|
||||
|
|
|
|||
37
surfsense_web/hooks/use-media-query.ts
Normal file
37
surfsense_web/hooks/use-media-query.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* Custom hook that tracks if a media query matches
|
||||
* @param query - The media query string to match (e.g., "(min-width: 768px)")
|
||||
* @returns boolean - True if the media query matches, false otherwise
|
||||
*/
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if we're in the browser (handle SSR)
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(query);
|
||||
|
||||
// Set initial value
|
||||
setMatches(mediaQuery.matches);
|
||||
|
||||
// Create event listener
|
||||
const handler = (event: MediaQueryListEvent) => {
|
||||
setMatches(event.matches);
|
||||
};
|
||||
|
||||
// Add event listener
|
||||
mediaQuery.addEventListener("change", handler);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
mediaQuery.removeEventListener("change", handler);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
33
surfsense_web/lib/pagination.ts
Normal file
33
surfsense_web/lib/pagination.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// Helper to normalize list responses from the API
|
||||
// Supports shapes: Array<T>, { items: T[]; total: number }, and tuple [T[], total]
|
||||
export type ListResponse<T> = {
|
||||
items: T[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export function normalizeListResponse<T>(payload: any): ListResponse<T> {
|
||||
try {
|
||||
// Case 1: already in desired shape
|
||||
if (payload && Array.isArray(payload.items)) {
|
||||
const total = typeof payload.total === "number" ? payload.total : payload.items.length;
|
||||
return { items: payload.items as T[], total };
|
||||
}
|
||||
|
||||
// Case 2: tuple [items, total]
|
||||
if (Array.isArray(payload) && payload.length === 2 && Array.isArray(payload[0])) {
|
||||
const items = (payload[0] ?? []) as T[];
|
||||
const rawTotal = payload[1];
|
||||
const total = typeof rawTotal === "number" ? rawTotal : items.length;
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
// Case 3: plain array
|
||||
if (Array.isArray(payload)) {
|
||||
return { items: payload as T[], total: (payload as T[]).length };
|
||||
}
|
||||
} catch (e) {
|
||||
// fallthrough to default
|
||||
}
|
||||
|
||||
return { items: [], total: 0 };
|
||||
}
|
||||
|
|
@ -12,12 +12,17 @@
|
|||
"debug": "cross-env NODE_OPTIONS=--inspect next dev --turbopack",
|
||||
"debug:browser": "cross-env NODE_OPTIONS=--inspect next dev --turbopack",
|
||||
"debug:server": "cross-env NODE_OPTIONS=--inspect=0.0.0.0:9229 next dev --turbopack",
|
||||
"postinstall": "fumadocs-mdx"
|
||||
"postinstall": "fumadocs-mdx",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:push": "drizzle-kit push",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^1.2.12",
|
||||
"@hookform/resolvers": "^4.1.3",
|
||||
"@llamaindex/chat-ui": "^0.5.17",
|
||||
"@number-flow/react": "^0.5.10",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
|
|
@ -32,6 +37,7 @@
|
|||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-toggle": "^1.1.9",
|
||||
"@radix-ui/react-toggle-group": "^1.1.10",
|
||||
|
|
@ -41,18 +47,23 @@
|
|||
"@types/mdx": "^2.0.13",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"ai": "^4.3.19",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.44.5",
|
||||
"emblor": "^1.4.8",
|
||||
"framer-motion": "^12.23.9",
|
||||
"fumadocs-core": "^15.6.6",
|
||||
"fumadocs-mdx": "^11.7.1",
|
||||
"fumadocs-ui": "^15.6.6",
|
||||
"geist": "^1.4.2",
|
||||
"lucide-react": "^0.477.0",
|
||||
"motion": "^12.23.22",
|
||||
"next": "^15.4.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.16.3",
|
||||
"postgres": "^3.4.7",
|
||||
"react": "^19.1.0",
|
||||
"react-day-picker": "^9.8.1",
|
||||
"react-dom": "^19.1.0",
|
||||
|
|
@ -63,6 +74,7 @@
|
|||
"react-markdown": "^10.1.0",
|
||||
"react-rough-notation": "^1.0.5",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"react-wrap-balancer": "^1.1.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
|
|
@ -76,13 +88,17 @@
|
|||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^20.19.9",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"cross-env": "^7.0.3",
|
||||
"drizzle-kit": "^0.31.5",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-config-next": "15.2.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
691
surfsense_web/pnpm-lock.yaml
generated
691
surfsense_web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
977
surfsense_web/public/contact/world.svg
Normal file
977
surfsense_web/public/contact/world.svg
Normal file
File diff suppressed because one or more lines are too long
BIN
surfsense_web/public/homepage/comments-audio.webp
Normal file
BIN
surfsense_web/public/homepage/comments-audio.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
BIN
surfsense_web/public/homepage/temp_hero_dark.png
Normal file
BIN
surfsense_web/public/homepage/temp_hero_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
surfsense_web/public/homepage/temp_hero_light.png
Normal file
BIN
surfsense_web/public/homepage/temp_hero_light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Loading…
Add table
Add a link
Reference in a new issue