mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
Merge branch 'dev' into dev
This commit is contained in:
commit
9973d51245
89 changed files with 5030 additions and 120 deletions
|
|
@ -10,7 +10,7 @@
|
|||
|
||||
|
||||
# SurfSense
|
||||
While tools like NotebookLM and Perplexity are impressive and highly effective for conducting research on any topic/query, SurfSense elevates this capability by integrating with your personal knowledge base. It is a highly customizable AI research agent, connected to external sources such as Search Engines (Tavily, LinkUp), Slack, Linear, Jira, ClickUp, Confluence, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar and more to come.
|
||||
While tools like NotebookLM and Perplexity are impressive and highly effective for conducting research on any topic/query, SurfSense elevates this capability by integrating with your personal knowledge base. It is a highly customizable AI research agent, connected to external sources such as Search Engines (Tavily, LinkUp), Slack, Linear, Jira, ClickUp, Confluence, Gmail, Notion, YouTube, GitHub, Discord, Airtable, Google Calendar, Luma and more to come.
|
||||
|
||||
<div align="center">
|
||||
<a href="https://trendshift.io/repositories/13606" target="_blank"><img src="https://trendshift.io/api/badge/repositories/13606" alt="MODSetter%2FSurfSense | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||
|
|
@ -74,6 +74,7 @@ Open source and easy to deploy locally.
|
|||
- Discord
|
||||
- Airtable
|
||||
- Google Calendar
|
||||
- Luma
|
||||
- and more to come.....
|
||||
|
||||
## 📄 **Supported File Extensions**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
"""Add Luma connector enums
|
||||
|
||||
Revision ID: 21
|
||||
Revises: 20
|
||||
Create Date: 2025-09-27 20:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "21"
|
||||
down_revision: str | None = "20"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Safely add 'LUMA_CONNECTOR' to enum types if missing."""
|
||||
|
||||
# Add to searchsourceconnectortype enum
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_type t
|
||||
JOIN pg_enum e ON t.oid = e.enumtypid
|
||||
WHERE t.typname = 'searchsourceconnectortype' AND e.enumlabel = 'LUMA_CONNECTOR'
|
||||
) THEN
|
||||
ALTER TYPE searchsourceconnectortype ADD VALUE 'LUMA_CONNECTOR';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
# Add to documenttype enum
|
||||
op.execute(
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_type t
|
||||
JOIN pg_enum e ON t.oid = e.enumtypid
|
||||
WHERE t.typname = 'documenttype' AND e.enumlabel = 'LUMA_CONNECTOR'
|
||||
) THEN
|
||||
ALTER TYPE documenttype ADD VALUE 'LUMA_CONNECTOR';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove 'LUMA_CONNECTOR' from enum types."""
|
||||
pass
|
||||
|
|
@ -413,6 +413,42 @@ async def fetch_documents_by_ids(
|
|||
else:
|
||||
url = ""
|
||||
|
||||
elif doc_type == "LUMA_CONNECTOR":
|
||||
# Extract Luma-specific metadata
|
||||
event_id = metadata.get("event_id", "")
|
||||
event_name = metadata.get("event_name", "Untitled Event")
|
||||
event_url = metadata.get("event_url", "")
|
||||
start_time = metadata.get("start_time", "")
|
||||
location_name = metadata.get("location_name", "")
|
||||
meeting_url = metadata.get("meeting_url", "")
|
||||
|
||||
title = f"Luma: {event_name}"
|
||||
if start_time:
|
||||
# Format the start time for display
|
||||
try:
|
||||
if "T" in start_time:
|
||||
from datetime import datetime
|
||||
|
||||
start_dt = datetime.fromisoformat(
|
||||
start_time.replace("Z", "+00:00")
|
||||
)
|
||||
formatted_time = start_dt.strftime("%Y-%m-%d %H:%M")
|
||||
title += f" ({formatted_time})"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
description = (
|
||||
doc.content[:100] + "..."
|
||||
if len(doc.content) > 100
|
||||
else doc.content
|
||||
)
|
||||
if location_name:
|
||||
description += f" | Venue: {location_name}"
|
||||
elif meeting_url:
|
||||
description += " | Online Event"
|
||||
|
||||
url = event_url if event_url else ""
|
||||
|
||||
elif doc_type == "EXTENSION":
|
||||
# Extract Extension-specific metadata
|
||||
webpage_title = metadata.get("VisitedWebPageTitle", doc.title)
|
||||
|
|
@ -487,6 +523,7 @@ async def fetch_documents_by_ids(
|
|||
"CONFLUENCE_CONNECTOR": "Confluence (Selected)",
|
||||
"CLICKUP_CONNECTOR": "ClickUp (Selected)",
|
||||
"AIRTABLE_CONNECTOR": "Airtable (Selected)",
|
||||
"LUMA_CONNECTOR": "Luma Events (Selected)",
|
||||
}
|
||||
|
||||
source_object = {
|
||||
|
|
@ -1197,6 +1234,33 @@ async def fetch_relevant_documents(
|
|||
}
|
||||
)
|
||||
|
||||
elif connector == "LUMA_CONNECTOR":
|
||||
(
|
||||
source_object,
|
||||
luma_chunks,
|
||||
) = await connector_service.search_luma(
|
||||
user_query=reformulated_query,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
top_k=top_k,
|
||||
search_mode=search_mode,
|
||||
)
|
||||
|
||||
# Add to sources and raw documents
|
||||
if source_object:
|
||||
all_sources.append(source_object)
|
||||
all_raw_documents.extend(luma_chunks)
|
||||
|
||||
# Stream found document count
|
||||
if streaming_service and writer:
|
||||
writer(
|
||||
{
|
||||
"yield_value": streaming_service.format_terminal_info_delta(
|
||||
f"🎯 Found {len(luma_chunks)} Luma events related to your query"
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logging.error("Error in search_airtable: %s", traceback.format_exc())
|
||||
error_message = f"Error searching connector {connector}: {e!s}"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ You are SurfSense, an advanced AI research assistant that provides detailed, wel
|
|||
- AIRTABLE_CONNECTOR: "Airtable records, tables, and database content" (personal data management and organization)
|
||||
- TAVILY_API: "Tavily search API results" (personalized search results)
|
||||
- LINKUP_API: "Linkup search API results" (personalized search results)
|
||||
- LUMA_CONNECTOR: "Luma events"
|
||||
</knowledge_sources>
|
||||
|
||||
<instructions>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ You are SurfSense, an advanced AI research assistant that synthesizes informatio
|
|||
- AIRTABLE_CONNECTOR: "Airtable records, tables, and database content" (personal data management and organization)
|
||||
- TAVILY_API: "Tavily search API results" (personalized search results)
|
||||
- LINKUP_API: "Linkup search API results" (personalized search results)
|
||||
- LUMA_CONNECTOR: "Luma events"
|
||||
</knowledge_sources>
|
||||
<instructions>
|
||||
1. Review the chat history to understand the conversation context and any previous topics discussed.
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ def get_connector_emoji(connector_name: str) -> str:
|
|||
"LINKUP_API": "🔗",
|
||||
"GOOGLE_CALENDAR_CONNECTOR": "📅",
|
||||
"AIRTABLE_CONNECTOR": "🗃️",
|
||||
"LUMA_CONNECTOR": "✨",
|
||||
}
|
||||
return connector_emojis.get(connector_name, "🔎")
|
||||
|
||||
|
|
@ -72,6 +73,7 @@ def get_connector_friendly_name(connector_name: str) -> str:
|
|||
"TAVILY_API": "Tavily Search",
|
||||
"LINKUP_API": "Linkup Search",
|
||||
"AIRTABLE_CONNECTOR": "Airtable",
|
||||
"LUMA_CONNECTOR": "Luma",
|
||||
}
|
||||
return connector_friendly_names.get(connector_name, connector_name)
|
||||
|
||||
|
|
|
|||
435
surfsense_backend/app/connectors/luma_connector.py
Normal file
435
surfsense_backend/app/connectors/luma_connector.py
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
"""
|
||||
Luma Connector Module
|
||||
|
||||
A module for retrieving events and guest data from Luma Event Platform.
|
||||
Allows fetching event lists, event details, and guest information with date range filtering.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class LumaConnector:
|
||||
"""Class for retrieving events and guest data from Luma Event Platform."""
|
||||
|
||||
def __init__(self, api_key: str | None = None):
|
||||
"""
|
||||
Initialize the LumaConnector class.
|
||||
|
||||
Args:
|
||||
api_key: Luma API key (optional, can be set later with set_api_key)
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.base_url = "https://public-api.luma.com/v1"
|
||||
|
||||
def set_api_key(self, api_key: str) -> None:
|
||||
"""
|
||||
Set the Luma API key.
|
||||
|
||||
Args:
|
||||
api_key: Luma API key
|
||||
"""
|
||||
self.api_key = api_key
|
||||
|
||||
def get_headers(self) -> dict[str, str]:
|
||||
"""
|
||||
Get headers for Luma API requests.
|
||||
|
||||
Returns:
|
||||
Dictionary of headers
|
||||
|
||||
Raises:
|
||||
ValueError: If no Luma API key has been set
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise ValueError("Luma API key not initialized. Call set_api_key() first.")
|
||||
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
"x-luma-api-key": self.api_key,
|
||||
}
|
||||
|
||||
def make_request(
|
||||
self, endpoint: str, params: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Make a request to the Luma API.
|
||||
|
||||
Args:
|
||||
endpoint: API endpoint path (without base URL)
|
||||
params: Query parameters (optional)
|
||||
|
||||
Returns:
|
||||
Response data from the API
|
||||
|
||||
Raises:
|
||||
ValueError: If no Luma API key has been set
|
||||
Exception: If the API request fails
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise ValueError("Luma API key not initialized. Call set_api_key() first.")
|
||||
|
||||
headers = self.get_headers()
|
||||
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
||||
|
||||
try:
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
elif response.status_code == 401:
|
||||
raise Exception("Unauthorized: Invalid Luma API key")
|
||||
elif response.status_code == 403:
|
||||
raise Exception(
|
||||
"Forbidden: Access denied or Luma Plus subscription required"
|
||||
)
|
||||
elif response.status_code == 429:
|
||||
raise Exception("Rate limit exceeded: Too many requests")
|
||||
else:
|
||||
raise Exception(
|
||||
f"API request failed with status code {response.status_code}: {response.text}"
|
||||
)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise Exception(f"Network error: {e}") from e
|
||||
|
||||
def get_user_info(self) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""
|
||||
Get information about the authenticated user.
|
||||
|
||||
Returns:
|
||||
Tuple containing (user info dict, error message or None)
|
||||
"""
|
||||
try:
|
||||
user_info = self.make_request("user/get-self")
|
||||
return user_info, None
|
||||
except Exception as e:
|
||||
return None, f"Error fetching user info: {e!s}"
|
||||
|
||||
def get_all_events(
|
||||
self, limit: int = 100
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""
|
||||
Fetch all events for the authenticated user.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of events to fetch per request (default: 100)
|
||||
|
||||
Returns:
|
||||
Tuple containing (events list, error message or None)
|
||||
"""
|
||||
try:
|
||||
all_events = []
|
||||
cursor = None
|
||||
|
||||
while True:
|
||||
params = {"limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
|
||||
response = self.make_request("calendar/list-events", params)
|
||||
|
||||
if "entries" not in response:
|
||||
break
|
||||
|
||||
events = response["entries"]
|
||||
all_events.extend(events)
|
||||
|
||||
# Check for pagination
|
||||
if response.get("next_cursor"):
|
||||
cursor = response["next_cursor"]
|
||||
else:
|
||||
break
|
||||
|
||||
return all_events, None
|
||||
|
||||
except Exception as e:
|
||||
return [], f"Error fetching events: {e!s}"
|
||||
|
||||
def get_event_details(
|
||||
self, event_id: str
|
||||
) -> tuple[dict[str, Any] | None, str | None]:
|
||||
"""
|
||||
Fetch detailed information about a specific event.
|
||||
|
||||
Args:
|
||||
event_id: The ID of the event to fetch details for
|
||||
|
||||
Returns:
|
||||
Tuple containing (event details dict, error message or None)
|
||||
"""
|
||||
try:
|
||||
event_details = self.make_request(f"events/{event_id}")
|
||||
return event_details, None
|
||||
except Exception as e:
|
||||
return None, f"Error fetching event details for {event_id}: {e!s}"
|
||||
|
||||
def get_event_guests(
|
||||
self, event_id: str, limit: int = 100
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""
|
||||
Fetch guests for a specific event.
|
||||
|
||||
Args:
|
||||
event_id: The ID of the event to fetch guests for
|
||||
limit: Maximum number of guests to fetch per request (default: 100)
|
||||
|
||||
Returns:
|
||||
Tuple containing (guests list, error message or None)
|
||||
"""
|
||||
try:
|
||||
all_guests = []
|
||||
cursor = None
|
||||
|
||||
while True:
|
||||
params = {"limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
|
||||
response = self.make_request(f"events/{event_id}/guests", params)
|
||||
|
||||
if "entries" not in response:
|
||||
break
|
||||
|
||||
guests = response["entries"]
|
||||
all_guests.extend(guests)
|
||||
|
||||
# Check for pagination
|
||||
if response.get("next_cursor"):
|
||||
cursor = response["next_cursor"]
|
||||
else:
|
||||
break
|
||||
|
||||
return all_guests, None
|
||||
|
||||
except Exception as e:
|
||||
return [], f"Error fetching guests for event {event_id}: {e!s}"
|
||||
|
||||
def get_events_by_date_range(
|
||||
self, start_date: str, end_date: str, include_guests: bool = True
|
||||
) -> tuple[list[dict[str, Any]], str | None]:
|
||||
"""
|
||||
Fetch events within a date range.
|
||||
|
||||
Args:
|
||||
start_date: Start date in YYYY-MM-DD format
|
||||
end_date: End date in YYYY-MM-DD format (inclusive)
|
||||
include_guests: Whether to include guest information for each event
|
||||
|
||||
Returns:
|
||||
Tuple containing (events list, error message or None)
|
||||
"""
|
||||
try:
|
||||
# Convert date strings to ISO format for comparison
|
||||
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
|
||||
|
||||
# Get all events first
|
||||
all_events, error = self.get_all_events()
|
||||
if error:
|
||||
return [], error
|
||||
|
||||
# Filter events by date range
|
||||
filtered_events = []
|
||||
for event in all_events:
|
||||
event_start_time = event.get("event", {}).get("start_at")
|
||||
if event_start_time:
|
||||
try:
|
||||
# Parse the event start time (assuming ISO format)
|
||||
event_dt = datetime.fromisoformat(
|
||||
event_start_time.replace("Z", "+00:00")
|
||||
)
|
||||
event_date = event_dt.date()
|
||||
|
||||
# Check if event falls within the date range
|
||||
if start_dt.date() <= event_date <= end_dt.date():
|
||||
# Add guest information if requested
|
||||
if include_guests:
|
||||
event_id = event.get("api_id")
|
||||
if event_id:
|
||||
guests, guest_error = self.get_event_guests(
|
||||
event_id
|
||||
)
|
||||
if not guest_error:
|
||||
event["guests"] = guests
|
||||
|
||||
filtered_events.append(event)
|
||||
except (ValueError, AttributeError):
|
||||
# Skip events with invalid dates
|
||||
continue
|
||||
|
||||
if not filtered_events:
|
||||
return [], "No events found in the specified date range."
|
||||
|
||||
return filtered_events, None
|
||||
|
||||
except ValueError as e:
|
||||
return [], f"Invalid date format: {e!s}. Please use YYYY-MM-DD."
|
||||
except Exception as e:
|
||||
return [], f"Error fetching events by date range: {e!s}"
|
||||
|
||||
def format_event_to_markdown(self, event: dict[str, Any]) -> str:
|
||||
"""
|
||||
Convert an event to markdown format.
|
||||
|
||||
Args:
|
||||
event: The event object from Luma API
|
||||
|
||||
Returns:
|
||||
Markdown string representation of the event
|
||||
"""
|
||||
# Extract event details
|
||||
event_data = event.get("event", {})
|
||||
|
||||
title = event_data.get("name", "Untitled Event")
|
||||
description = event_data.get("description", "")
|
||||
event_id = event.get("api_id", "")
|
||||
|
||||
# Extract timing information
|
||||
start_at = event_data.get("start_at", "")
|
||||
end_at = event_data.get("end_at", "")
|
||||
timezone = event_data.get("timezone", "")
|
||||
|
||||
# Format dates
|
||||
start_formatted = self.format_date(start_at) if start_at else "Unknown"
|
||||
end_formatted = self.format_date(end_at) if end_at else "Unknown"
|
||||
|
||||
# Extract location information
|
||||
geo_info = event_data.get("geo_info", {})
|
||||
location_name = geo_info.get("name", "")
|
||||
address = geo_info.get("address", "")
|
||||
|
||||
# Extract other details
|
||||
url = event_data.get("url", "")
|
||||
visibility = event_data.get("visibility", "")
|
||||
meeting_url = event_data.get("meeting_url", "")
|
||||
|
||||
# Build markdown content
|
||||
markdown_content = f"# {title}\n\n"
|
||||
|
||||
if event_id:
|
||||
markdown_content += f"**Event ID:** {event_id}\n"
|
||||
|
||||
# Add timing information
|
||||
markdown_content += f"**Start:** {start_formatted}\n"
|
||||
markdown_content += f"**End:** {end_formatted}\n"
|
||||
|
||||
if timezone:
|
||||
markdown_content += f"**Timezone:** {timezone}\n"
|
||||
|
||||
markdown_content += "\n"
|
||||
|
||||
# Add location information
|
||||
if location_name or address:
|
||||
markdown_content += "## Location\n\n"
|
||||
if location_name:
|
||||
markdown_content += f"**Venue:** {location_name}\n"
|
||||
if address:
|
||||
markdown_content += f"**Address:** {address}\n"
|
||||
markdown_content += "\n"
|
||||
|
||||
# Add online meeting info
|
||||
if meeting_url:
|
||||
markdown_content += f"**Meeting URL:** {meeting_url}\n\n"
|
||||
|
||||
# Add description if available
|
||||
if description:
|
||||
markdown_content += f"## Description\n\n{description}\n\n"
|
||||
|
||||
# Add event details
|
||||
markdown_content += "## Event Details\n\n"
|
||||
|
||||
if url:
|
||||
markdown_content += f"- **Event URL:** {url}\n"
|
||||
|
||||
if visibility:
|
||||
markdown_content += f"- **Visibility:** {visibility}\n"
|
||||
|
||||
# Add guest information if available
|
||||
if "guests" in event:
|
||||
guests = event["guests"]
|
||||
markdown_content += f"\n## Guests ({len(guests)})\n\n"
|
||||
|
||||
for guest in guests[:10]: # Show first 10 guests
|
||||
guest_data = guest.get("guest", {})
|
||||
name = guest_data.get("name", "Unknown")
|
||||
email = guest_data.get("email", "")
|
||||
status = guest.get("registration_status", "unknown")
|
||||
|
||||
markdown_content += f"- **{name}**"
|
||||
if email:
|
||||
markdown_content += f" ({email})"
|
||||
markdown_content += f" - Status: {status}\n"
|
||||
|
||||
if len(guests) > 10:
|
||||
markdown_content += f"- ... and {len(guests) - 10} more guests\n"
|
||||
|
||||
markdown_content += "\n"
|
||||
|
||||
return markdown_content
|
||||
|
||||
@staticmethod
|
||||
def format_date(iso_date: str) -> str:
|
||||
"""
|
||||
Format an ISO date string to a more readable format.
|
||||
|
||||
Args:
|
||||
iso_date: ISO format date string
|
||||
|
||||
Returns:
|
||||
Formatted date string
|
||||
"""
|
||||
if not iso_date or not isinstance(iso_date, str):
|
||||
return "Unknown date"
|
||||
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso_date.replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S %Z")
|
||||
except ValueError:
|
||||
return iso_date
|
||||
|
||||
|
||||
# Example usage (uncomment to use):
|
||||
"""
|
||||
if __name__ == "__main__":
|
||||
# Set your API key here
|
||||
api_key = "YOUR_LUMA_API_KEY"
|
||||
|
||||
luma = LumaConnector(api_key)
|
||||
|
||||
try:
|
||||
# Test authentication
|
||||
user_info, error = luma.get_user_info()
|
||||
if error:
|
||||
print(f"Authentication error: {error}")
|
||||
else:
|
||||
print(f"Authenticated as: {user_info.get('name', 'Unknown')}")
|
||||
|
||||
# Get all events
|
||||
events, error = luma.get_all_events()
|
||||
if error:
|
||||
print(f"Error fetching events: {error}")
|
||||
else:
|
||||
print(f"Retrieved {len(events)} events")
|
||||
|
||||
# Format and print the first event as markdown
|
||||
if events:
|
||||
event_md = luma.format_event_to_markdown(events[0])
|
||||
print("\nSample Event in Markdown:\n")
|
||||
print(event_md)
|
||||
|
||||
# Get events by date range
|
||||
start_date = "2023-01-01"
|
||||
end_date = "2023-01-31"
|
||||
date_events, error = luma.get_events_by_date_range(start_date, end_date)
|
||||
|
||||
if error:
|
||||
print(f"Error: {error}")
|
||||
else:
|
||||
print(f"\nRetrieved {len(date_events)} events from {start_date} to {end_date}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
"""
|
||||
|
|
@ -49,6 +49,7 @@ class DocumentType(str, Enum):
|
|||
GOOGLE_CALENDAR_CONNECTOR = "GOOGLE_CALENDAR_CONNECTOR"
|
||||
GOOGLE_GMAIL_CONNECTOR = "GOOGLE_GMAIL_CONNECTOR"
|
||||
AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR"
|
||||
LUMA_CONNECTOR = "LUMA_CONNECTOR"
|
||||
|
||||
|
||||
class SearchSourceConnectorType(str, Enum):
|
||||
|
|
@ -66,6 +67,7 @@ class SearchSourceConnectorType(str, Enum):
|
|||
GOOGLE_CALENDAR_CONNECTOR = "GOOGLE_CALENDAR_CONNECTOR"
|
||||
GOOGLE_GMAIL_CONNECTOR = "GOOGLE_GMAIL_CONNECTOR"
|
||||
AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR"
|
||||
LUMA_CONNECTOR = "LUMA_CONNECTOR"
|
||||
|
||||
|
||||
class ChatType(str, Enum):
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from .google_gmail_add_connector_route import (
|
|||
)
|
||||
from .llm_config_routes import router as llm_config_router
|
||||
from .logs_routes import router as logs_router
|
||||
from .luma_add_connector_route import router as luma_add_connector_router
|
||||
from .podcasts_routes import router as podcasts_router
|
||||
from .search_source_connectors_routes import router as search_source_connectors_router
|
||||
from .search_spaces_routes import router as search_spaces_router
|
||||
|
|
@ -27,5 +28,6 @@ router.include_router(search_source_connectors_router)
|
|||
router.include_router(google_calendar_add_connector_router)
|
||||
router.include_router(google_gmail_add_connector_router)
|
||||
router.include_router(airtable_add_connector_router)
|
||||
router.include_router(luma_add_connector_router)
|
||||
router.include_router(llm_config_router)
|
||||
router.include_router(logs_router)
|
||||
|
|
|
|||
|
|
@ -280,3 +280,78 @@ async def airtable_callback(
|
|||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to complete Airtable OAuth: {e!s}"
|
||||
) from e
|
||||
|
||||
|
||||
async def refresh_airtable_token(
|
||||
session: AsyncSession, connector: SearchSourceConnector
|
||||
):
|
||||
"""
|
||||
Refresh the Airtable access token for a connector.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
connector: Airtable connector to refresh
|
||||
|
||||
Returns:
|
||||
Updated connector object
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Refreshing Airtable token for connector {connector.id}")
|
||||
|
||||
credentials = AirtableAuthCredentialsBase.from_dict(connector.config)
|
||||
auth_header = make_basic_auth_header(
|
||||
config.AIRTABLE_CLIENT_ID, config.AIRTABLE_CLIENT_SECRET
|
||||
)
|
||||
|
||||
# Prepare token refresh data
|
||||
refresh_data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": credentials.refresh_token,
|
||||
"client_id": config.AIRTABLE_CLIENT_ID,
|
||||
"client_secret": config.AIRTABLE_CLIENT_SECRET,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
token_response = await client.post(
|
||||
TOKEN_URL,
|
||||
data=refresh_data,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": auth_header,
|
||||
},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Token refresh failed: {token_response.text}"
|
||||
)
|
||||
|
||||
token_json = token_response.json()
|
||||
|
||||
# Calculate expiration time (UTC, tz-aware)
|
||||
expires_at = None
|
||||
if token_json.get("expires_in"):
|
||||
now_utc = datetime.now(UTC)
|
||||
expires_at = now_utc + timedelta(seconds=int(token_json["expires_in"]))
|
||||
|
||||
# Update credentials object
|
||||
credentials.access_token = token_json["access_token"]
|
||||
credentials.expires_in = token_json.get("expires_in")
|
||||
credentials.expires_at = expires_at
|
||||
credentials.scope = token_json.get("scope")
|
||||
|
||||
# Update connector config
|
||||
connector.config = credentials.to_dict()
|
||||
await session.commit()
|
||||
await session.refresh(connector)
|
||||
|
||||
logger.info(
|
||||
f"Successfully refreshed Airtable token for connector {connector.id}"
|
||||
)
|
||||
|
||||
return connector
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to refresh Airtable token: {e!s}"
|
||||
) from e
|
||||
|
|
|
|||
242
surfsense_backend/app/routes/luma_add_connector_route.py
Normal file
242
surfsense_backend/app/routes/luma_add_connector_route.py
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.future import select
|
||||
|
||||
from app.db import (
|
||||
SearchSourceConnector,
|
||||
SearchSourceConnectorType,
|
||||
User,
|
||||
get_async_session,
|
||||
)
|
||||
from app.users import current_active_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class AddLumaConnectorRequest(BaseModel):
|
||||
"""Request model for adding a Luma connector."""
|
||||
|
||||
api_key: str = Field(..., description="Luma API key")
|
||||
space_id: int = Field(..., description="Search space ID")
|
||||
|
||||
|
||||
@router.post("/connectors/luma/add")
|
||||
async def add_luma_connector(
|
||||
request: AddLumaConnectorRequest,
|
||||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Add a new Luma connector for the authenticated user.
|
||||
|
||||
Args:
|
||||
request: The request containing Luma API key and space_id
|
||||
user: Current authenticated user
|
||||
session: Database session
|
||||
|
||||
Returns:
|
||||
Success message and connector details
|
||||
|
||||
Raises:
|
||||
HTTPException: If connector already exists or validation fails
|
||||
"""
|
||||
try:
|
||||
# Check if a Luma connector already exists for this user
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.user_id == user.id,
|
||||
SearchSourceConnector.connector_type
|
||||
== SearchSourceConnectorType.LUMA_CONNECTOR,
|
||||
)
|
||||
)
|
||||
existing_connector = result.scalars().first()
|
||||
|
||||
if existing_connector:
|
||||
# Update existing connector with new API key
|
||||
existing_connector.config = {"api_key": request.api_key}
|
||||
existing_connector.is_indexable = True
|
||||
await session.commit()
|
||||
await session.refresh(existing_connector)
|
||||
|
||||
logger.info(f"Updated existing Luma connector for user {user.id}")
|
||||
|
||||
return {
|
||||
"message": "Luma connector updated successfully",
|
||||
"connector_id": existing_connector.id,
|
||||
"connector_type": "LUMA_CONNECTOR",
|
||||
}
|
||||
|
||||
# Create new Luma connector
|
||||
db_connector = SearchSourceConnector(
|
||||
name="Luma Event Connector",
|
||||
connector_type=SearchSourceConnectorType.LUMA_CONNECTOR,
|
||||
config={"api_key": request.api_key},
|
||||
user_id=user.id,
|
||||
is_indexable=True,
|
||||
)
|
||||
|
||||
session.add(db_connector)
|
||||
await session.commit()
|
||||
await session.refresh(db_connector)
|
||||
|
||||
logger.info(
|
||||
f"Successfully created Luma connector for user {user.id} with ID {db_connector.id}"
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Luma connector added successfully",
|
||||
"connector_id": db_connector.id,
|
||||
"connector_type": "LUMA_CONNECTOR",
|
||||
}
|
||||
|
||||
except IntegrityError as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Database integrity error: {e!s}")
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="A Luma connector already exists for this user.",
|
||||
) from e
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Unexpected error adding Luma connector: {e!s}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to add Luma connector: {e!s}",
|
||||
) from e
|
||||
|
||||
|
||||
@router.delete("/connectors/luma")
|
||||
async def delete_luma_connector(
|
||||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Delete the Luma connector for the authenticated user.
|
||||
|
||||
Args:
|
||||
user: Current authenticated user
|
||||
session: Database session
|
||||
|
||||
Returns:
|
||||
Success message
|
||||
|
||||
Raises:
|
||||
HTTPException: If connector doesn't exist
|
||||
"""
|
||||
try:
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.user_id == user.id,
|
||||
SearchSourceConnector.connector_type
|
||||
== SearchSourceConnectorType.LUMA_CONNECTOR,
|
||||
)
|
||||
)
|
||||
connector = result.scalars().first()
|
||||
|
||||
if not connector:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Luma connector not found for this user.",
|
||||
)
|
||||
|
||||
await session.delete(connector)
|
||||
await session.commit()
|
||||
|
||||
logger.info(f"Successfully deleted Luma connector for user {user.id}")
|
||||
|
||||
return {"message": "Luma connector deleted successfully"}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.error(f"Unexpected error deleting Luma connector: {e!s}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to delete Luma connector: {e!s}",
|
||||
) from e
|
||||
|
||||
|
||||
@router.get("/connectors/luma/test")
|
||||
async def test_luma_connector(
|
||||
user: User = Depends(current_active_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
):
|
||||
"""
|
||||
Test the Luma connector for the authenticated user.
|
||||
|
||||
Args:
|
||||
user: Current authenticated user
|
||||
session: Database session
|
||||
|
||||
Returns:
|
||||
Test results including user info and event count
|
||||
|
||||
Raises:
|
||||
HTTPException: If connector doesn't exist or test fails
|
||||
"""
|
||||
try:
|
||||
# Get the Luma connector for this user
|
||||
result = await session.execute(
|
||||
select(SearchSourceConnector).filter(
|
||||
SearchSourceConnector.user_id == user.id,
|
||||
SearchSourceConnector.connector_type
|
||||
== SearchSourceConnectorType.LUMA_CONNECTOR,
|
||||
)
|
||||
)
|
||||
connector = result.scalars().first()
|
||||
|
||||
if not connector:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="Luma connector not found. Please add a connector first.",
|
||||
)
|
||||
|
||||
# Import LumaConnector
|
||||
from app.connectors.luma_connector import LumaConnector
|
||||
|
||||
# Initialize the connector
|
||||
api_key = connector.config.get("api_key")
|
||||
if not api_key:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid connector configuration: API key missing.",
|
||||
)
|
||||
|
||||
luma = LumaConnector(api_key=api_key)
|
||||
|
||||
# Test the connection by fetching user info
|
||||
user_info, error = luma.get_user_info()
|
||||
if error:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to connect to Luma: {error}",
|
||||
)
|
||||
|
||||
# Try to fetch events
|
||||
events, events_error = luma.get_all_events(limit=10)
|
||||
|
||||
return {
|
||||
"message": "Luma connector is working correctly",
|
||||
"user_info": {
|
||||
"name": user_info.get("name", "Unknown"),
|
||||
"email": user_info.get("email", "Unknown"),
|
||||
},
|
||||
"event_count": len(events) if not events_error else 0,
|
||||
"events_error": events_error,
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error testing Luma connector: {e!s}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to test Luma connector: {e!s}",
|
||||
) from e
|
||||
|
|
@ -7,7 +7,7 @@ PUT /search-source-connectors/{connector_id} - Update a specific connector
|
|||
DELETE /search-source-connectors/{connector_id} - Delete a specific connector
|
||||
POST /search-source-connectors/{connector_id}/index - Index content from a connector to a search space
|
||||
|
||||
Note: Each user can have only one connector of each type (SERPER_API, TAVILY_API, SLACK_CONNECTOR, NOTION_CONNECTOR, GITHUB_CONNECTOR, LINEAR_CONNECTOR, DISCORD_CONNECTOR).
|
||||
Note: Each user can have only one connector of each type (SERPER_API, TAVILY_API, SLACK_CONNECTOR, NOTION_CONNECTOR, GITHUB_CONNECTOR, LINEAR_CONNECTOR, DISCORD_CONNECTOR, LUMA_CONNECTOR).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
|
@ -45,6 +45,7 @@ from app.tasks.connector_indexers import (
|
|||
index_google_gmail_messages,
|
||||
index_jira_issues,
|
||||
index_linear_issues,
|
||||
index_luma_events,
|
||||
index_notion_pages,
|
||||
index_slack_messages,
|
||||
)
|
||||
|
|
@ -344,6 +345,7 @@ async def index_connector_content(
|
|||
- LINEAR_CONNECTOR: Indexes issues and comments from Linear
|
||||
- JIRA_CONNECTOR: Indexes issues and comments from Jira
|
||||
- DISCORD_CONNECTOR: Indexes messages from all accessible Discord channels
|
||||
- LUMA_CONNECTOR: Indexes events from Luma
|
||||
|
||||
Args:
|
||||
connector_id: ID of the connector to use
|
||||
|
|
@ -555,6 +557,21 @@ async def index_connector_content(
|
|||
)
|
||||
response_message = "Discord indexing started in the background."
|
||||
|
||||
elif connector.connector_type == SearchSourceConnectorType.LUMA_CONNECTOR:
|
||||
# Run indexing in background
|
||||
logger.info(
|
||||
f"Triggering Luma indexing for connector {connector_id} into search space {search_space_id} from {indexing_from} to {indexing_to}"
|
||||
)
|
||||
background_tasks.add_task(
|
||||
run_luma_indexing_with_new_session,
|
||||
connector_id,
|
||||
search_space_id,
|
||||
str(user.id),
|
||||
indexing_from,
|
||||
indexing_to,
|
||||
)
|
||||
response_message = "Luma indexing started in the background."
|
||||
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
@ -1262,3 +1279,65 @@ async def run_google_gmail_indexing(
|
|||
exc_info=True,
|
||||
)
|
||||
# Optionally update status in DB to indicate failure
|
||||
|
||||
|
||||
# Add new helper functions for luma indexing
|
||||
async def run_luma_indexing_with_new_session(
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
):
|
||||
"""
|
||||
Create a new session and run the Luma indexing task.
|
||||
This prevents session leaks by creating a dedicated session for the background task.
|
||||
"""
|
||||
async with async_session_maker() as session:
|
||||
await run_luma_indexing(
|
||||
session, connector_id, search_space_id, user_id, start_date, end_date
|
||||
)
|
||||
|
||||
|
||||
async def run_luma_indexing(
|
||||
session: AsyncSession,
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
):
|
||||
"""
|
||||
Background task to run Luma indexing.
|
||||
Args:
|
||||
session: Database session
|
||||
connector_id: ID of the Luma connector
|
||||
search_space_id: ID of the search space
|
||||
user_id: ID of the user
|
||||
start_date: Start date for indexing
|
||||
end_date: End date for indexing
|
||||
"""
|
||||
try:
|
||||
# Index Luma events without updating last_indexed_at (we'll do it separately)
|
||||
documents_processed, error_or_warning = await index_luma_events(
|
||||
session=session,
|
||||
connector_id=connector_id,
|
||||
search_space_id=search_space_id,
|
||||
user_id=user_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
update_last_indexed=False, # Don't update timestamp in the indexing function
|
||||
)
|
||||
|
||||
# Only update last_indexed_at if indexing was successful (either new docs or updated docs)
|
||||
if documents_processed > 0:
|
||||
await update_connector_last_indexed(session, connector_id)
|
||||
logger.info(
|
||||
f"Luma indexing completed successfully: {documents_processed} documents processed"
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"Luma indexing failed or no documents processed: {error_or_warning}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in background Luma indexing task: {e!s}")
|
||||
|
|
|
|||
|
|
@ -196,6 +196,18 @@ class SearchSourceConnectorBase(BaseModel):
|
|||
if key not in config or config[key] in (None, ""):
|
||||
raise ValueError(f"{key} is required and cannot be empty")
|
||||
|
||||
elif connector_type == SearchSourceConnectorType.LUMA_CONNECTOR:
|
||||
# For LUMA_CONNECTOR, only allow LUMA_API_KEY
|
||||
allowed_keys = ["LUMA_API_KEY"]
|
||||
if set(config.keys()) != set(allowed_keys):
|
||||
raise ValueError(
|
||||
f"For LUMA_CONNECTOR connector type, config must only contain these keys: {allowed_keys}"
|
||||
)
|
||||
|
||||
# Ensure the api key is not empty
|
||||
if not config.get("LUMA_API_KEY"):
|
||||
raise ValueError("LUMA_API_KEY cannot be empty")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1852,3 +1852,164 @@ class ConnectorService:
|
|||
}
|
||||
|
||||
return result_object, discord_chunks
|
||||
|
||||
async def search_luma(
|
||||
self,
|
||||
user_query: str,
|
||||
user_id: str,
|
||||
search_space_id: int,
|
||||
top_k: int = 20,
|
||||
search_mode: SearchMode = SearchMode.CHUNKS,
|
||||
) -> tuple:
|
||||
"""
|
||||
Search for Luma events and return both the source information and langchain documents
|
||||
|
||||
Args:
|
||||
user_query: The user's query
|
||||
user_id: The user's ID
|
||||
search_space_id: The search space ID to search in
|
||||
top_k: Maximum number of results to return
|
||||
search_mode: Search mode (CHUNKS or DOCUMENTS)
|
||||
|
||||
Returns:
|
||||
tuple: (sources_info, langchain_documents)
|
||||
"""
|
||||
if search_mode == SearchMode.CHUNKS:
|
||||
luma_chunks = await self.chunk_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="LUMA_CONNECTOR",
|
||||
)
|
||||
elif search_mode == SearchMode.DOCUMENTS:
|
||||
luma_chunks = await self.document_retriever.hybrid_search(
|
||||
query_text=user_query,
|
||||
top_k=top_k,
|
||||
user_id=user_id,
|
||||
search_space_id=search_space_id,
|
||||
document_type="LUMA_CONNECTOR",
|
||||
)
|
||||
# Transform document retriever results to match expected format
|
||||
luma_chunks = self._transform_document_results(luma_chunks)
|
||||
|
||||
# Early return if no results
|
||||
if not luma_chunks:
|
||||
return {
|
||||
"id": 33,
|
||||
"name": "Luma Events",
|
||||
"type": "LUMA_CONNECTOR",
|
||||
"sources": [],
|
||||
}, []
|
||||
|
||||
# Process each chunk and create sources directly without deduplication
|
||||
sources_list = []
|
||||
async with self.counter_lock:
|
||||
for _i, chunk in enumerate(luma_chunks):
|
||||
# Extract document metadata
|
||||
document = chunk.get("document", {})
|
||||
metadata = document.get("metadata", {})
|
||||
|
||||
# Extract Luma-specific metadata
|
||||
event_id = metadata.get("event_id", "")
|
||||
event_name = metadata.get("event_name", "Untitled Event")
|
||||
event_url = metadata.get("event_url", "")
|
||||
start_time = metadata.get("start_time", "")
|
||||
end_time = metadata.get("end_time", "")
|
||||
location_name = metadata.get("location_name", "")
|
||||
location_address = metadata.get("location_address", "")
|
||||
meeting_url = metadata.get("meeting_url", "")
|
||||
timezone = metadata.get("timezone", "")
|
||||
visibility = metadata.get("visibility", "")
|
||||
|
||||
# Create a more descriptive title for Luma events
|
||||
title = f"Luma: {event_name}"
|
||||
if start_time:
|
||||
# Format the start time for display
|
||||
try:
|
||||
if "T" in start_time:
|
||||
from datetime import datetime
|
||||
|
||||
start_dt = datetime.fromisoformat(
|
||||
start_time.replace("Z", "+00:00")
|
||||
)
|
||||
formatted_time = start_dt.strftime("%Y-%m-%d %H:%M")
|
||||
title += f" ({formatted_time})"
|
||||
else:
|
||||
title += f" ({start_time})"
|
||||
except Exception:
|
||||
title += f" ({start_time})"
|
||||
|
||||
# Create a more descriptive description for Luma events
|
||||
description = chunk.get("content", "")[:150]
|
||||
if len(description) == 150:
|
||||
description += "..."
|
||||
|
||||
# Add event info to description
|
||||
info_parts = []
|
||||
if location_name:
|
||||
info_parts.append(f"Venue: {location_name}")
|
||||
elif location_address:
|
||||
info_parts.append(f"Location: {location_address}")
|
||||
|
||||
if meeting_url:
|
||||
info_parts.append("Online Event")
|
||||
|
||||
if end_time:
|
||||
try:
|
||||
if "T" in end_time:
|
||||
from datetime import datetime
|
||||
|
||||
end_dt = datetime.fromisoformat(
|
||||
end_time.replace("Z", "+00:00")
|
||||
)
|
||||
formatted_end = end_dt.strftime("%Y-%m-%d %H:%M")
|
||||
info_parts.append(f"Ends: {formatted_end}")
|
||||
else:
|
||||
info_parts.append(f"Ends: {end_time}")
|
||||
except Exception:
|
||||
info_parts.append(f"Ends: {end_time}")
|
||||
|
||||
if timezone:
|
||||
info_parts.append(f"TZ: {timezone}")
|
||||
|
||||
if visibility:
|
||||
info_parts.append(f"Visibility: {visibility.title()}")
|
||||
|
||||
if info_parts:
|
||||
if description:
|
||||
description += f" | {' | '.join(info_parts)}"
|
||||
else:
|
||||
description = " | ".join(info_parts)
|
||||
|
||||
# Use the Luma event URL if available
|
||||
url = event_url if event_url else ""
|
||||
|
||||
source = {
|
||||
"id": chunk.get("chunk_id", self.source_id_counter),
|
||||
"title": title,
|
||||
"description": description,
|
||||
"url": url,
|
||||
"event_id": event_id,
|
||||
"event_name": event_name,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
"location_name": location_name,
|
||||
"location_address": location_address,
|
||||
"meeting_url": meeting_url,
|
||||
"timezone": timezone,
|
||||
"visibility": visibility,
|
||||
}
|
||||
|
||||
self.source_id_counter += 1
|
||||
sources_list.append(source)
|
||||
|
||||
# Create result object
|
||||
result_object = {
|
||||
"id": 33, # Assign a unique ID for the Luma connector
|
||||
"name": "Luma Events",
|
||||
"type": "LUMA_CONNECTOR",
|
||||
"sources": sources_list,
|
||||
}
|
||||
|
||||
return result_object, luma_chunks
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ Available indexers:
|
|||
- ClickUp: Index tasks from ClickUp workspaces
|
||||
- Google Gmail: Index messages from Google Gmail
|
||||
- Google Calendar: Index events from Google Calendar
|
||||
- Luma: Index events from Luma
|
||||
"""
|
||||
|
||||
# Communication platforms
|
||||
|
|
@ -33,6 +34,7 @@ from .jira_indexer import index_jira_issues
|
|||
|
||||
# Issue tracking and project management
|
||||
from .linear_indexer import index_linear_issues
|
||||
from .luma_indexer import index_luma_events
|
||||
|
||||
# Documentation and knowledge management
|
||||
from .notion_indexer import index_notion_pages
|
||||
|
|
@ -47,6 +49,7 @@ __all__ = [ # noqa: RUF022
|
|||
"index_github_repos",
|
||||
# Calendar and scheduling
|
||||
"index_google_calendar_events",
|
||||
"index_luma_events",
|
||||
"index_jira_issues",
|
||||
# Issue tracking and project management
|
||||
"index_linear_issues",
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from app.config import config
|
||||
from app.connectors.airtable_connector import AirtableConnector
|
||||
from app.db import Document, DocumentType, SearchSourceConnectorType
|
||||
from app.routes.airtable_add_connector_route import refresh_airtable_token
|
||||
from app.schemas.airtable_auth_credentials import AirtableAuthCredentialsBase
|
||||
from app.services.llm_service import get_user_long_context_llm
|
||||
from app.services.task_logging_service import TaskLoggingService
|
||||
|
|
@ -102,7 +103,10 @@ async def index_airtable_records(
|
|||
"Credentials expired",
|
||||
{"error_type": "ExpiredCredentials"},
|
||||
)
|
||||
return 0, "Airtable credentials have expired. Please re-authenticate."
|
||||
|
||||
connector = await refresh_airtable_token(session, connector)
|
||||
|
||||
# return 0, "Airtable credentials have expired. Please re-authenticate."
|
||||
|
||||
# Calculate date range for indexing
|
||||
start_date_str, end_date_str = calculate_date_range(
|
||||
|
|
|
|||
401
surfsense_backend/app/tasks/connector_indexers/luma_indexer.py
Normal file
401
surfsense_backend/app/tasks/connector_indexers/luma_indexer.py
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
"""
|
||||
Luma connector indexer.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import config
|
||||
from app.connectors.luma_connector import LumaConnector
|
||||
from app.db import Document, DocumentType, SearchSourceConnectorType
|
||||
from app.services.llm_service import get_user_long_context_llm
|
||||
from app.services.task_logging_service import TaskLoggingService
|
||||
from app.utils.document_converters import (
|
||||
create_document_chunks,
|
||||
generate_content_hash,
|
||||
generate_document_summary,
|
||||
)
|
||||
|
||||
from .base import (
|
||||
get_connector_by_id,
|
||||
logger,
|
||||
update_connector_last_indexed,
|
||||
)
|
||||
|
||||
|
||||
async def index_luma_events(
|
||||
session: AsyncSession,
|
||||
connector_id: int,
|
||||
search_space_id: int,
|
||||
user_id: str,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
update_last_indexed: bool = True,
|
||||
) -> tuple[int, str | None]:
|
||||
"""
|
||||
Index Luma events.
|
||||
|
||||
Args:
|
||||
session: Database session
|
||||
connector_id: ID of the Luma connector
|
||||
search_space_id: ID of the search space to store documents in
|
||||
user_id: User ID
|
||||
start_date: Start date for indexing (YYYY-MM-DD format)
|
||||
end_date: End date for indexing (YYYY-MM-DD format)
|
||||
update_last_indexed: Whether to update the last_indexed_at timestamp (default: True)
|
||||
|
||||
Returns:
|
||||
Tuple containing (number of documents indexed, error message or None)
|
||||
"""
|
||||
task_logger = TaskLoggingService(session, search_space_id)
|
||||
|
||||
# Log task start
|
||||
log_entry = await task_logger.log_task_start(
|
||||
task_name="luma_events_indexing",
|
||||
source="connector_indexing_task",
|
||||
message=f"Starting Luma events indexing for connector {connector_id}",
|
||||
metadata={
|
||||
"connector_id": connector_id,
|
||||
"user_id": str(user_id),
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Get the connector
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Retrieving Luma connector {connector_id} from database",
|
||||
{"stage": "connector_retrieval"},
|
||||
)
|
||||
|
||||
# Get the connector from the database
|
||||
connector = await get_connector_by_id(
|
||||
session, connector_id, SearchSourceConnectorType.LUMA_CONNECTOR
|
||||
)
|
||||
|
||||
if not connector:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Connector with ID {connector_id} not found or is not a Luma connector",
|
||||
"Connector not found",
|
||||
{"error_type": "ConnectorNotFound"},
|
||||
)
|
||||
return (
|
||||
0,
|
||||
f"Connector with ID {connector_id} not found or is not a Luma connector",
|
||||
)
|
||||
|
||||
# Get the Luma API key from the connector config
|
||||
api_key = connector.config.get("LUMA_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Luma API key not found in connector config for connector {connector_id}",
|
||||
"Missing Luma API key",
|
||||
{"error_type": "MissingCredentials"},
|
||||
)
|
||||
return 0, "Luma API key not found in connector config"
|
||||
|
||||
logger.info(f"Starting Luma indexing for connector {connector_id}")
|
||||
|
||||
# Initialize Luma client
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Initializing Luma client for connector {connector_id}",
|
||||
{"stage": "client_initialization"},
|
||||
)
|
||||
|
||||
luma_client = LumaConnector(api_key=api_key)
|
||||
|
||||
# Calculate date range
|
||||
if start_date is None or end_date is None:
|
||||
# Fall back to calculating dates based on last_indexed_at
|
||||
calculated_end_date = datetime.now()
|
||||
|
||||
# Use last_indexed_at as start date if available, otherwise use 30 days ago
|
||||
if connector.last_indexed_at:
|
||||
# Convert dates to be comparable (both timezone-naive)
|
||||
last_indexed_naive = (
|
||||
connector.last_indexed_at.replace(tzinfo=None)
|
||||
if connector.last_indexed_at.tzinfo
|
||||
else connector.last_indexed_at
|
||||
)
|
||||
|
||||
# Check if last_indexed_at is in the future or after end_date
|
||||
if last_indexed_naive > calculated_end_date:
|
||||
logger.warning(
|
||||
f"Last indexed date ({last_indexed_naive.strftime('%Y-%m-%d')}) is in the future. Using 30 days ago instead."
|
||||
)
|
||||
calculated_start_date = calculated_end_date - timedelta(days=30)
|
||||
else:
|
||||
calculated_start_date = last_indexed_naive
|
||||
logger.info(
|
||||
f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date"
|
||||
)
|
||||
else:
|
||||
calculated_start_date = calculated_end_date - timedelta(days=30)
|
||||
logger.info(
|
||||
f"No last_indexed_at found, using {calculated_start_date.strftime('%Y-%m-%d')} (30 days ago) as start date"
|
||||
)
|
||||
|
||||
# Use calculated dates if not provided
|
||||
start_date_str = (
|
||||
start_date if start_date else calculated_start_date.strftime("%Y-%m-%d")
|
||||
)
|
||||
end_date_str = (
|
||||
end_date if end_date else calculated_end_date.strftime("%Y-%m-%d")
|
||||
)
|
||||
else:
|
||||
# Use provided dates
|
||||
start_date_str = start_date
|
||||
end_date_str = end_date
|
||||
|
||||
await task_logger.log_task_progress(
|
||||
log_entry,
|
||||
f"Fetching Luma events from {start_date_str} to {end_date_str}",
|
||||
{
|
||||
"stage": "fetching_events",
|
||||
"start_date": start_date_str,
|
||||
"end_date": end_date_str,
|
||||
},
|
||||
)
|
||||
|
||||
# Get events within date range from Luma
|
||||
try:
|
||||
events, error = luma_client.get_events_by_date_range(
|
||||
start_date_str, end_date_str, include_guests=False
|
||||
)
|
||||
|
||||
if error:
|
||||
logger.error(f"Failed to get Luma events: {error}")
|
||||
|
||||
# Don't treat "No events found" as an error that should stop indexing
|
||||
if "No events found" in error or "no events" in error.lower():
|
||||
logger.info(
|
||||
"No events found is not a critical error, continuing with update"
|
||||
)
|
||||
if update_last_indexed:
|
||||
await update_connector_last_indexed(
|
||||
session, connector, update_last_indexed
|
||||
)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
f"Updated last_indexed_at to {connector.last_indexed_at} despite no events found"
|
||||
)
|
||||
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
f"No Luma events found in date range {start_date_str} to {end_date_str}",
|
||||
{"events_found": 0},
|
||||
)
|
||||
return 0, None
|
||||
else:
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Failed to get Luma events: {error}",
|
||||
"API Error",
|
||||
{"error_type": "APIError"},
|
||||
)
|
||||
return 0, f"Failed to get Luma events: {error}"
|
||||
|
||||
logger.info(f"Retrieved {len(events)} events from Luma API")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Luma events: {e!s}", exc_info=True)
|
||||
return 0, f"Error fetching Luma events: {e!s}"
|
||||
|
||||
documents_indexed = 0
|
||||
documents_skipped = 0
|
||||
skipped_events = []
|
||||
|
||||
for event in events:
|
||||
try:
|
||||
# Luma event structure fields - events have nested 'event' field
|
||||
event_data = event.get("event", {})
|
||||
event_id = event.get("api_id") or event_data.get("id")
|
||||
event_name = event_data.get("name", "No Title")
|
||||
event_url = event_data.get("url", "")
|
||||
|
||||
if not event_id:
|
||||
logger.warning(f"Skipping event with missing ID: {event_name}")
|
||||
skipped_events.append(f"{event_name} (missing ID)")
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Format event to markdown using Luma connector's method
|
||||
event_markdown = luma_client.format_event_to_markdown(event)
|
||||
if not event_markdown.strip():
|
||||
logger.warning(f"Skipping event with no content: {event_name}")
|
||||
skipped_events.append(f"{event_name} (no content)")
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Extract Luma-specific fields from event_data
|
||||
start_at = event_data.get("start_at", "")
|
||||
end_at = event_data.get("end_at", "")
|
||||
timezone = event_data.get("timezone", "")
|
||||
|
||||
# Location info from geo_info
|
||||
geo_info = event_data.get("geo_info", {})
|
||||
location = geo_info.get("address", "")
|
||||
city = geo_info.get("city", "")
|
||||
|
||||
# Host info
|
||||
hosts = event_data.get("hosts", [])
|
||||
host_names = ", ".join(
|
||||
[host.get("name", "") for host in hosts if host.get("name")]
|
||||
)
|
||||
|
||||
description = event_data.get("description", "")
|
||||
cover_url = event_data.get("cover_url", "")
|
||||
|
||||
content_hash = generate_content_hash(event_markdown, search_space_id)
|
||||
|
||||
# Duplicate check via simple query using helper in base
|
||||
from .base import check_duplicate_document_by_hash
|
||||
|
||||
existing_document_by_hash = await check_duplicate_document_by_hash(
|
||||
session, content_hash
|
||||
)
|
||||
if existing_document_by_hash:
|
||||
logger.info(
|
||||
f"Document with content hash {content_hash} already exists for event {event_name}. Skipping processing."
|
||||
)
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
# Generate summary with metadata
|
||||
user_llm = await get_user_long_context_llm(session, user_id)
|
||||
|
||||
if user_llm:
|
||||
document_metadata = {
|
||||
"event_id": event_id,
|
||||
"event_name": event_name,
|
||||
"event_url": event_url,
|
||||
"start_at": start_at,
|
||||
"end_at": end_at,
|
||||
"timezone": timezone,
|
||||
"location": location or "No location",
|
||||
"city": city,
|
||||
"hosts": host_names,
|
||||
"document_type": "Luma Event",
|
||||
"connector_type": "Luma",
|
||||
}
|
||||
(
|
||||
summary_content,
|
||||
summary_embedding,
|
||||
) = await generate_document_summary(
|
||||
event_markdown, user_llm, document_metadata
|
||||
)
|
||||
else:
|
||||
# Fallback to simple summary if no LLM configured
|
||||
summary_content = f"Luma Event: {event_name}\n\n"
|
||||
if event_url:
|
||||
summary_content += f"URL: {event_url}\n"
|
||||
summary_content += f"Start: {start_at}\n"
|
||||
summary_content += f"End: {end_at}\n"
|
||||
if timezone:
|
||||
summary_content += f"Timezone: {timezone}\n"
|
||||
if location:
|
||||
summary_content += f"Location: {location}\n"
|
||||
if city:
|
||||
summary_content += f"City: {city}\n"
|
||||
if host_names:
|
||||
summary_content += f"Hosts: {host_names}\n"
|
||||
if description:
|
||||
desc_preview = description[:300]
|
||||
if len(description) > 300:
|
||||
desc_preview += "..."
|
||||
summary_content += f"Description: {desc_preview}\n"
|
||||
|
||||
summary_embedding = config.embedding_model_instance.embed(
|
||||
summary_content
|
||||
)
|
||||
|
||||
chunks = await create_document_chunks(event_markdown)
|
||||
|
||||
document = Document(
|
||||
search_space_id=search_space_id,
|
||||
title=f"Luma Event - {event_name}",
|
||||
document_type=DocumentType.LUMA_CONNECTOR,
|
||||
document_metadata={
|
||||
"event_id": event_id,
|
||||
"event_name": event_name,
|
||||
"event_url": event_url,
|
||||
"start_at": start_at,
|
||||
"end_at": end_at,
|
||||
"timezone": timezone,
|
||||
"location": location,
|
||||
"city": city,
|
||||
"hosts": host_names,
|
||||
"cover_url": cover_url,
|
||||
"indexed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
},
|
||||
content=summary_content,
|
||||
content_hash=content_hash,
|
||||
embedding=summary_embedding,
|
||||
chunks=chunks,
|
||||
)
|
||||
|
||||
session.add(document)
|
||||
documents_indexed += 1
|
||||
logger.info(f"Successfully indexed new event {event_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error processing event {event.get('name', 'Unknown')}: {e!s}",
|
||||
exc_info=True,
|
||||
)
|
||||
skipped_events.append(
|
||||
f"{event.get('name', 'Unknown')} (processing error)"
|
||||
)
|
||||
documents_skipped += 1
|
||||
continue
|
||||
|
||||
total_processed = documents_indexed
|
||||
if total_processed > 0:
|
||||
await update_connector_last_indexed(session, connector, update_last_indexed)
|
||||
|
||||
await session.commit()
|
||||
|
||||
await task_logger.log_task_success(
|
||||
log_entry,
|
||||
f"Successfully completed Luma indexing for connector {connector_id}",
|
||||
{
|
||||
"events_processed": total_processed,
|
||||
"documents_indexed": documents_indexed,
|
||||
"documents_skipped": documents_skipped,
|
||||
"skipped_events_count": len(skipped_events),
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Luma indexing completed: {documents_indexed} new events, {documents_skipped} skipped"
|
||||
)
|
||||
return total_processed, None
|
||||
|
||||
except SQLAlchemyError as db_error:
|
||||
await session.rollback()
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Database error during Luma indexing for connector {connector_id}",
|
||||
str(db_error),
|
||||
{"error_type": "SQLAlchemyError"},
|
||||
)
|
||||
logger.error(f"Database error: {db_error!s}", exc_info=True)
|
||||
return 0, f"Database error: {db_error!s}"
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
await task_logger.log_task_failure(
|
||||
log_entry,
|
||||
f"Failed to index Luma events for connector {connector_id}",
|
||||
str(e),
|
||||
{"error_type": type(e).__name__},
|
||||
)
|
||||
logger.error(f"Failed to index Luma events: {e!s}", exc_info=True)
|
||||
return 0, f"Failed to index Luma events: {e!s}"
|
||||
|
|
@ -136,13 +136,14 @@ async def add_youtube_video_document(
|
|||
)
|
||||
|
||||
try:
|
||||
captions = YouTubeTranscriptApi.get_transcript(video_id)
|
||||
ytt_api = YouTubeTranscriptApi()
|
||||
captions = ytt_api.fetch(video_id)
|
||||
# Include complete caption information with timestamps
|
||||
transcript_segments = []
|
||||
for line in captions:
|
||||
start_time = line.get("start", 0)
|
||||
duration = line.get("duration", 0)
|
||||
text = line.get("text", "")
|
||||
start_time = line.start
|
||||
duration = line.duration
|
||||
text = line.text
|
||||
timestamp = f"[{start_time:.2f}s-{start_time + duration:.2f}s]"
|
||||
transcript_segments.append(f"{timestamp} {text}")
|
||||
transcript_text = "\n".join(transcript_segments)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
12
surfsense_web/app/contact/page.tsx
Normal file
12
surfsense_web/app/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
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { AnimatePresence, motion, type Variants } from "framer-motion";
|
||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import {
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { Calendar as CalendarIcon, Edit, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
|
@ -260,6 +260,17 @@ export default function EditConnectorPage() {
|
|||
placeholder="Bot token..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* == Luma == */}
|
||||
{connector.connector_type === "LUMA_CONNECTOR" && (
|
||||
<EditSimpleTokenForm
|
||||
control={editForm.control}
|
||||
fieldName="LUMA_API_KEY"
|
||||
fieldLabel="Luma API Key"
|
||||
fieldDescription="Update the Luma API Key if needed."
|
||||
placeholder="API Key..."
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="border-t pt-6">
|
||||
<Button type="submit" disabled={isSaving} className="w-full sm:w-auto">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
@ -53,6 +53,7 @@ const getConnectorTypeDisplay = (type: string): string => {
|
|||
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar Connector",
|
||||
GOOGLE_GMAIL_CONNECTOR: "Google Gmail Connector",
|
||||
AIRTABLE_CONNECTOR: "Airtable Connector",
|
||||
LUMA_CONNECTOR: "Luma Connector",
|
||||
// Add other connector types here as needed
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
|
|
@ -71,6 +72,7 @@ const getApiKeyFieldName = (connectorType: string): string => {
|
|||
GITHUB_CONNECTOR: "GITHUB_PAT",
|
||||
DISCORD_CONNECTOR: "DISCORD_BOT_TOKEN",
|
||||
LINKUP_API: "LINKUP_API_KEY",
|
||||
LUMA_CONNECTOR: "LUMA_API_KEY",
|
||||
};
|
||||
return fieldMap[connectorType] || "";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, CircleAlert, Github, Info, ListChecks, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, ExternalLink, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Key, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EnumConnectorName } from "@/contracts/enums/connector";
|
||||
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
|
||||
import {
|
||||
type SearchSourceConnector,
|
||||
useSearchSourceConnectors,
|
||||
} from "@/hooks/useSearchSourceConnectors";
|
||||
|
||||
// Define the form schema with Zod
|
||||
const lumaConnectorFormSchema = z.object({
|
||||
name: z.string().min(3, {
|
||||
message: "Connector name must be at least 3 characters.",
|
||||
}),
|
||||
api_key: z.string().min(10, {
|
||||
message: "API key is required and must be valid.",
|
||||
}),
|
||||
});
|
||||
|
||||
// Define the type for the form values
|
||||
type LumaConnectorFormValues = z.infer<typeof lumaConnectorFormSchema>;
|
||||
|
||||
export default function LumaConnectorPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const searchSpaceId = params.search_space_id as string;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [doesConnectorExist, setDoesConnectorExist] = useState(false);
|
||||
|
||||
const { fetchConnectors, createConnector } = useSearchSourceConnectors();
|
||||
|
||||
// Initialize the form
|
||||
const form = useForm<LumaConnectorFormValues>({
|
||||
resolver: zodResolver(lumaConnectorFormSchema),
|
||||
defaultValues: {
|
||||
name: "Luma Events",
|
||||
api_key: "",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConnectors().then((data) => {
|
||||
const connector = data.find(
|
||||
(c: SearchSourceConnector) => c.connector_type === EnumConnectorName.LUMA_CONNECTOR
|
||||
);
|
||||
if (connector) {
|
||||
setDoesConnectorExist(true);
|
||||
}
|
||||
});
|
||||
}, [fetchConnectors]);
|
||||
|
||||
// Handle form submission
|
||||
const onSubmit = async (values: LumaConnectorFormValues) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createConnector({
|
||||
name: values.name,
|
||||
connector_type: EnumConnectorName.LUMA_CONNECTOR,
|
||||
config: {
|
||||
LUMA_API_KEY: values.api_key,
|
||||
},
|
||||
is_indexable: true,
|
||||
last_indexed_at: null,
|
||||
});
|
||||
|
||||
toast.success("Luma connector created successfully!");
|
||||
|
||||
// Navigate back to connectors page
|
||||
router.push(`/dashboard/${searchSpaceId}/connectors`);
|
||||
} catch (error) {
|
||||
console.error("Error creating connector:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create connector");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8 max-w-2xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Link
|
||||
href={`/dashboard/${searchSpaceId}/connectors/add`}
|
||||
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground mb-4"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to connectors
|
||||
</Link>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
{getConnectorIcon(EnumConnectorName.LUMA_CONNECTOR, "h-6 w-6")}
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Connect Luma</h1>
|
||||
<p className="text-muted-foreground">Connect your Luma account to search events.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Card */}
|
||||
{!doesConnectorExist ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Connect Your Luma Account</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your Luma API key to connect your account. We'll use this to access your
|
||||
events in read-only mode.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Connector Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My Luma Events" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A friendly name to identify this connector.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Enter your Luma API key" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Your API key will be encrypted and stored securely.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Read-only access to your Luma events</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>Access works even when you're offline</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
<span>You can disconnect anytime</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.push(`/dashboard/${searchSpaceId}/connectors/add`)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Connecting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="mr-2 h-4 w-4" />
|
||||
Connect Luma
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</Card>
|
||||
) : (
|
||||
/* Success Card */
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>✅ Your Luma account is successfully connected!</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Help Section */}
|
||||
{!doesConnectorExist && (
|
||||
<Card className="mt-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">How It Works</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">1. Get Your API Key</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Log into your Luma account and navigate to your account settings to generate an
|
||||
API key.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">2. Enter Your API Key</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Paste your API key in the field above. We'll use this to securely access your
|
||||
events with read-only permissions.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -140,6 +140,13 @@ const connectorCategories: ConnectorCategory[] = [
|
|||
icon: getConnectorIcon(EnumConnectorName.AIRTABLE_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
{
|
||||
id: "luma-connector",
|
||||
title: "Luma",
|
||||
description: "Connect to Luma to search events",
|
||||
icon: getConnectorIcon(EnumConnectorName.LUMA_CONNECTOR, "h-6 w-6"),
|
||||
status: "available",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ArrowLeft, Check, Info, Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, type Variants } from "framer-motion";
|
||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ChevronDown, ChevronUp, FileX } from "lucide-react";
|
||||
import React from "react";
|
||||
import { DocumentViewer } from "@/components/document-viewer";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useId, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { IconBrandYoutube } from "@tabler/icons-react";
|
||||
import { type Tag, TagInput } from "emblor";
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table";
|
||||
import { AnimatePresence, motion, type Variants } from "framer-motion";
|
||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { AlertCircle, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
|
|
|
|||
|
|
@ -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,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,12 +1,16 @@
|
|||
"use client";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
|
||||
export function LocalLoginForm() {
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorTitle, setErrorTitle] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [authType, setAuthType] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
|
@ -19,7 +23,11 @@ export function LocalLoginForm() {
|
|||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
setError(null); // Clear any previous errors
|
||||
setErrorTitle(null);
|
||||
|
||||
// Show loading toast
|
||||
const loadingToast = toast.loading("Signing you in...");
|
||||
|
||||
try {
|
||||
// Create form data for the API request
|
||||
|
|
@ -42,13 +50,53 @@ export function LocalLoginForm() {
|
|||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || "Failed to login");
|
||||
throw new Error(data.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
router.push(`/auth/callback?token=${data.access_token}`);
|
||||
// Success toast
|
||||
toast.success("Login successful!", {
|
||||
id: loadingToast,
|
||||
description: "Redirecting to dashboard...",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Small delay to show success message
|
||||
setTimeout(() => {
|
||||
router.push(`/auth/callback?token=${data.access_token}`);
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "An error occurred during login";
|
||||
setError(errorMessage);
|
||||
// Use auth-errors utility to get proper error details
|
||||
let errorCode = "UNKNOWN_ERROR";
|
||||
|
||||
if (err instanceof Error) {
|
||||
errorCode = err.message;
|
||||
} else if (isNetworkError(err)) {
|
||||
errorCode = "NETWORK_ERROR";
|
||||
}
|
||||
|
||||
// Get detailed error information from auth-errors utility
|
||||
const errorDetails = getAuthErrorDetails(errorCode);
|
||||
|
||||
// Set persistent error display
|
||||
setErrorTitle(errorDetails.title);
|
||||
setError(errorDetails.description);
|
||||
|
||||
// Show error toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
id: loadingToast,
|
||||
description: errorDetails.description,
|
||||
duration: 6000,
|
||||
};
|
||||
|
||||
// Add retry action if the error is retryable
|
||||
if (shouldRetry(errorCode)) {
|
||||
toastOptions.action = {
|
||||
label: "Retry",
|
||||
onClick: () => handleSubmit(e),
|
||||
};
|
||||
}
|
||||
|
||||
toast.error(errorDetails.title, toastOptions);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -57,11 +105,67 @@ export function LocalLoginForm() {
|
|||
return (
|
||||
<div className="w-full max-w-md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{/* Error Display */}
|
||||
<AnimatePresence>
|
||||
{error && errorTitle && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-900 shadow-sm dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-200"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0 mt-0.5 text-red-500 dark:text-red-400"
|
||||
>
|
||||
<title>Error Icon</title>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold mb-1">{errorTitle}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setErrorTitle(null);
|
||||
}}
|
||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
||||
aria-label="Dismiss error"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<title>Close</title>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div>
|
||||
<label
|
||||
|
|
@ -76,7 +180,12 @@ export function LocalLoginForm() {
|
|||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -93,14 +202,19 @@ export function LocalLoginForm() {
|
|||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,23 +1,82 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { getAuthErrorDetails, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AmbientBackground } from "./AmbientBackground";
|
||||
import { GoogleLoginButton } from "./GoogleLoginButton";
|
||||
import { LocalLoginForm } from "./LocalLoginForm";
|
||||
|
||||
function LoginContent() {
|
||||
const [authType, setAuthType] = useState<string | null>(null);
|
||||
const [registrationSuccess, setRegistrationSuccess] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [urlError, setUrlError] = useState<{ title: string; message: string } | null>(null);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if the user was redirected from registration
|
||||
if (searchParams.get("registered") === "true") {
|
||||
setRegistrationSuccess(true);
|
||||
// Check for various URL parameters that might indicate success or error states
|
||||
const registered = searchParams.get("registered");
|
||||
const error = searchParams.get("error");
|
||||
const message = searchParams.get("message");
|
||||
const logout = searchParams.get("logout");
|
||||
|
||||
// Show registration success message
|
||||
if (registered === "true") {
|
||||
toast.success("Registration successful!", {
|
||||
description: "You can now sign in with your credentials",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
// Show logout confirmation
|
||||
if (logout === "true") {
|
||||
toast.success("Logged out successfully", {
|
||||
description: "You have been securely logged out",
|
||||
duration: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
// Show error messages from OAuth or other flows using auth-errors utility
|
||||
if (error) {
|
||||
// Use the auth-errors utility to get proper error details
|
||||
const errorDetails = getAuthErrorDetails(error);
|
||||
|
||||
// If we have a custom message from URL params, use it as description
|
||||
const errorDescription = message ? decodeURIComponent(message) : errorDetails.description;
|
||||
|
||||
// Set persistent error display
|
||||
setUrlError({
|
||||
title: errorDetails.title,
|
||||
message: errorDescription,
|
||||
});
|
||||
|
||||
// Show toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
description: errorDescription,
|
||||
duration: 6000,
|
||||
};
|
||||
|
||||
// Add retry action if the error is retryable
|
||||
if (shouldRetry(error)) {
|
||||
toastOptions.action = {
|
||||
label: "Retry",
|
||||
onClick: () => window.location.reload(),
|
||||
};
|
||||
}
|
||||
|
||||
toast.error(errorDetails.title, toastOptions);
|
||||
}
|
||||
|
||||
// Show general messages
|
||||
if (message && !error && !registered && !logout) {
|
||||
toast.info("Notice", {
|
||||
description: decodeURIComponent(message),
|
||||
duration: 4000,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the auth type from environment variables
|
||||
|
|
@ -54,11 +113,64 @@ function LoginContent() {
|
|||
Sign In
|
||||
</h1>
|
||||
|
||||
{registrationSuccess && (
|
||||
<div className="mb-4 w-full rounded-md bg-green-50 p-4 text-sm text-green-500 dark:bg-green-900/20 dark:text-green-200">
|
||||
Registration successful! You can now sign in with your credentials.
|
||||
</div>
|
||||
)}
|
||||
{/* URL Error Display */}
|
||||
<AnimatePresence>
|
||||
{urlError && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="mb-6 w-full max-w-md rounded-lg border border-red-200 bg-red-50 p-4 text-red-900 shadow-sm dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-200"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0 mt-0.5 text-red-500 dark:text-red-400"
|
||||
>
|
||||
<title>Error Icon</title>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold mb-1">{urlError.title}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{urlError.message}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setUrlError(null)}
|
||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
||||
aria-label="Dismiss error"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<title>Close</title>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<LocalLoginForm />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { ArrowLeft, ArrowRight, Bot, CheckCircle, Sparkles } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
|
|||
12
surfsense_web/app/pricing/page.tsx
Normal file
12
surfsense_web/app/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,16 +1,20 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Logo } from "@/components/Logo";
|
||||
import { getAuthErrorDetails, isNetworkError, shouldRetry } from "@/lib/auth-errors";
|
||||
import { AmbientBackground } from "../login/AmbientBackground";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorTitle, setErrorTitle] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -28,11 +32,20 @@ export default function RegisterPage() {
|
|||
// Form validation
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
setErrorTitle("Password Mismatch");
|
||||
toast.error("Password Mismatch", {
|
||||
description: "The passwords you entered do not match",
|
||||
duration: 4000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
setError(null); // Clear any previous errors
|
||||
setErrorTitle(null);
|
||||
|
||||
// Show loading toast
|
||||
const loadingToast = toast.loading("Creating your account...");
|
||||
|
||||
try {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/auth/register`, {
|
||||
|
|
@ -52,15 +65,53 @@ export default function RegisterPage() {
|
|||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.detail || "Registration failed");
|
||||
throw new Error(data.detail || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Redirect to login page after successful registration
|
||||
router.push("/login?registered=true");
|
||||
} catch (err: unknown) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "An error occurred during registration";
|
||||
setError(errorMessage);
|
||||
// Success toast
|
||||
toast.success("Account created successfully!", {
|
||||
id: loadingToast,
|
||||
description: "Redirecting to login page...",
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Small delay to show success message
|
||||
setTimeout(() => {
|
||||
router.push("/login?registered=true");
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
// Use auth-errors utility to get proper error details
|
||||
let errorCode = "UNKNOWN_ERROR";
|
||||
|
||||
if (err instanceof Error) {
|
||||
errorCode = err.message;
|
||||
} else if (isNetworkError(err)) {
|
||||
errorCode = "NETWORK_ERROR";
|
||||
}
|
||||
|
||||
// Get detailed error information from auth-errors utility
|
||||
const errorDetails = getAuthErrorDetails(errorCode);
|
||||
|
||||
// Set persistent error display
|
||||
setErrorTitle(errorDetails.title);
|
||||
setError(errorDetails.description);
|
||||
|
||||
// Show error toast with conditional retry action
|
||||
const toastOptions: any = {
|
||||
id: loadingToast,
|
||||
description: errorDetails.description,
|
||||
duration: 6000,
|
||||
};
|
||||
|
||||
// Add retry action if the error is retryable
|
||||
if (shouldRetry(errorCode)) {
|
||||
toastOptions.action = {
|
||||
label: "Retry",
|
||||
onClick: () => handleSubmit(e),
|
||||
};
|
||||
}
|
||||
|
||||
toast.error(errorDetails.title, toastOptions);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -77,11 +128,67 @@ export default function RegisterPage() {
|
|||
|
||||
<div className="w-full max-w-md">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-red-50 p-4 text-sm text-red-500 dark:bg-red-900/20 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{/* Enhanced Error Display */}
|
||||
<AnimatePresence>
|
||||
{error && errorTitle && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -10, scale: 0.95 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-900 shadow-sm dark:border-red-900/30 dark:bg-red-900/20 dark:text-red-200"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="flex-shrink-0 mt-0.5 text-red-500 dark:text-red-400"
|
||||
>
|
||||
<title>Error Icon</title>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" y1="9" x2="9" y2="15" />
|
||||
<line x1="9" y1="9" x2="15" y2="15" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold mb-1">{errorTitle}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setErrorTitle(null);
|
||||
}}
|
||||
className="flex-shrink-0 text-red-500 hover:text-red-700 dark:text-red-400 dark:hover:text-red-200 transition-colors"
|
||||
aria-label="Dismiss error"
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<title>Close</title>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div>
|
||||
<label
|
||||
|
|
@ -96,7 +203,12 @@ export default function RegisterPage() {
|
|||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -113,7 +225,12 @@ export default function RegisterPage() {
|
|||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -130,14 +247,19 @@ export default function RegisterPage() {
|
|||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="mt-1 block w-full rounded-md border border-gray-300 bg-white px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:text-white"
|
||||
className={`mt-1 block w-full rounded-md border px-3 py-2 shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:bg-gray-800 dark:text-white transition-colors ${
|
||||
error
|
||||
? "border-red-300 focus:border-red-500 focus:ring-red-500 dark:border-red-700"
|
||||
: "border-gray-300 focus:border-blue-500 focus:ring-blue-500 dark:border-gray-700"
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="w-full rounded-md bg-blue-600 px-4 py-2 text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isLoading ? "Creating account..." : "Register"}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -3,43 +3,49 @@ 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/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,5 +1,5 @@
|
|||
"use client";
|
||||
import { IconBrandDiscord, IconBrandGithub, IconFileTypeDoc } from "@tabler/icons-react";
|
||||
import { IconBrandDiscord, IconBrandGithub, IconFileTypeDoc, IconMail } from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "./Logo";
|
||||
|
|
@ -54,6 +54,13 @@ export function ModernHeroWithGradients() {
|
|||
Notion, YouTube, GitHub, Discord and more.
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-6 py-6 sm:flex-row">
|
||||
<Link
|
||||
href="/contact"
|
||||
className="w-48 gap-1 rounded-full border border-blue-400 bg-gradient-to-b from-blue-100 to-blue-300 px-5 py-3 text-center text-sm font-medium text-blue-900 shadow-sm dark:border-blue-700 dark:bg-gradient-to-b dark:from-blue-900 dark:to-blue-700 dark:text-blue-100 dark:shadow-inner dark:shadow-blue-500/20 flex items-center justify-center"
|
||||
>
|
||||
<IconMail className="h-5 w-5 mr-2 text-blue-700 dark:text-blue-300" />
|
||||
<span>Contact Us</span>
|
||||
</Link>
|
||||
<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"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
import { IconMenu2, IconUser, IconX } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "framer-motion";
|
||||
import { IconMail, IconMenu2, IconUser, IconX } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion, useMotionValueEvent, useScroll } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -167,7 +167,17 @@ const DesktopNav = ({ navItems, visible }: NavbarProps) => {
|
|||
duration: 0.2,
|
||||
},
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Link href="/contact">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden cursor-pointer md:flex items-center gap-2 rounded-full dark:bg-blue-900/40 dark:hover:bg-blue-800/50 dark:text-blue-100 dark:border-blue-700 bg-blue-100 hover:bg-blue-200 text-blue-900 border-blue-400"
|
||||
>
|
||||
<IconMail className="h-4 w-4" />
|
||||
<span>Contact Us</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
|
|
@ -270,6 +280,15 @@ const MobileNav = ({ navItems, visible }: NavbarProps) => {
|
|||
<motion.span className="block">{navItem.name}</motion.span>
|
||||
</Link>
|
||||
))}
|
||||
<Link href="/contact" className="w-full" onClick={() => setOpen(false)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex cursor-pointer items-center gap-2 mt-4 w-full justify-center rounded-full dark:bg-blue-900/40 dark:hover:bg-blue-800/50 dark:text-blue-100 dark:border-blue-700 bg-blue-100 hover:bg-blue-200 text-blue-900 border-blue-400"
|
||||
>
|
||||
<IconMail className="h-4 w-4" />
|
||||
<span>Contact Us</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
onClick={handleGoogleLogin}
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
357
surfsense_web/components/contact/contact-form.tsx
Normal file
357
surfsense_web/components/contact/contact-form.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
"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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ export function DashboardBreadcrumb() {
|
|||
"tavily-api": "Tavily API",
|
||||
"serper-api": "Serper API",
|
||||
"linkup-api": "LinkUp API",
|
||||
"luma-connector": "Luma",
|
||||
};
|
||||
|
||||
const connectorLabel = connectorLabels[connectorType] || connectorType;
|
||||
|
|
|
|||
|
|
@ -43,5 +43,6 @@ export const editConnectorSchema = z.object({
|
|||
GOOGLE_CALENDAR_CLIENT_SECRET: z.string().optional(),
|
||||
GOOGLE_CALENDAR_REFRESH_TOKEN: z.string().optional(),
|
||||
GOOGLE_CALENDAR_CALENDAR_IDS: z.string().optional(),
|
||||
LUMA_API_KEY: z.string().optional(),
|
||||
});
|
||||
export type EditConnectorFormValues = z.infer<typeof editConnectorSchema>;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
202
surfsense_web/components/pricing.tsx
Normal file
202
surfsense_web/components/pricing.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
"use client";
|
||||
|
||||
import NumberFlow from "@number-flow/react";
|
||||
import confetti from "canvas-confetti";
|
||||
import { motion } from "motion/react";
|
||||
import { Check, Star } from "lucide-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">
|
||||
<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="grid grid-cols-1 md:grid-cols-3 sm:2 gap-4">
|
||||
{plans.map((plan, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ y: 50, opacity: 1 }}
|
||||
whileInView={
|
||||
isDesktop
|
||||
? {
|
||||
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",
|
||||
index === 0 || index === 2
|
||||
? "z-0 transform translate-x-0 translate-y-0 -translate-z-[50px] rotate-y-[10deg]"
|
||||
: "z-10",
|
||||
index === 0 && "origin-right",
|
||||
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">
|
||||
<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 !== "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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
74
surfsense_web/components/pricing/pricing-section.tsx
Normal file
74
surfsense_web/components/pricing/pricing-section.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { Pricing } from "@/components/pricing";
|
||||
|
||||
const demoPlans = [
|
||||
{
|
||||
name: "STARTER",
|
||||
price: "50",
|
||||
yearlyPrice: "40",
|
||||
period: "per month",
|
||||
features: [
|
||||
"Up to 10 projects",
|
||||
"Basic analytics",
|
||||
"48-hour support response time",
|
||||
"Limited API access",
|
||||
"Community support",
|
||||
],
|
||||
description: "Perfect for individuals and small projects",
|
||||
buttonText: "Start Free Trial",
|
||||
href: "/sign-up",
|
||||
isPopular: false,
|
||||
},
|
||||
{
|
||||
name: "PROFESSIONAL",
|
||||
price: "99",
|
||||
yearlyPrice: "79",
|
||||
period: "per month",
|
||||
features: [
|
||||
"Unlimited projects",
|
||||
"Advanced analytics",
|
||||
"24-hour support response time",
|
||||
"Full API access",
|
||||
"Priority support",
|
||||
"Team collaboration",
|
||||
"Custom integrations",
|
||||
],
|
||||
description: "Ideal for growing teams and businesses",
|
||||
buttonText: "Get Started",
|
||||
href: "/sign-up",
|
||||
isPopular: true,
|
||||
},
|
||||
{
|
||||
name: "ENTERPRISE",
|
||||
price: "299",
|
||||
yearlyPrice: "239",
|
||||
period: "per month",
|
||||
features: [
|
||||
"Everything in Professional",
|
||||
"Custom solutions",
|
||||
"Dedicated account manager",
|
||||
"1-hour support response time",
|
||||
"SSO Authentication",
|
||||
"Advanced security",
|
||||
"Custom contracts",
|
||||
"SLA agreement",
|
||||
],
|
||||
description: "For large organizations with specific needs",
|
||||
buttonText: "Contact Sales",
|
||||
href: "/contact",
|
||||
isPopular: false,
|
||||
},
|
||||
];
|
||||
|
||||
function PricingBasic() {
|
||||
return (
|
||||
<Pricing
|
||||
plans={demoPlans}
|
||||
title="Simple, Transparent Pricing"
|
||||
description="Choose the plan 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";
|
||||
|
|
@ -112,12 +112,12 @@ const LLM_PROVIDERS = [
|
|||
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: "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",
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -13,4 +13,5 @@ export enum EnumConnectorName {
|
|||
GOOGLE_CALENDAR_CONNECTOR = "GOOGLE_CALENDAR_CONNECTOR",
|
||||
GOOGLE_GMAIL_CONNECTOR = "GOOGLE_GMAIL_CONNECTOR",
|
||||
AIRTABLE_CONNECTOR = "AIRTABLE_CONNECTOR",
|
||||
LUMA_CONNECTOR = "LUMA_CONNECTOR",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
IconLayoutKanban,
|
||||
IconLinkPlus,
|
||||
IconMail,
|
||||
IconSparkles,
|
||||
IconTable,
|
||||
IconTicket,
|
||||
IconWorldWww,
|
||||
|
|
@ -49,6 +50,8 @@ export const getConnectorIcon = (connectorType: EnumConnectorName | string, clas
|
|||
return <IconBook {...iconProps} />;
|
||||
case EnumConnectorName.CLICKUP_CONNECTOR:
|
||||
return <IconChecklist {...iconProps} />;
|
||||
case EnumConnectorName.LUMA_CONNECTOR:
|
||||
return <IconSparkles {...iconProps} />;
|
||||
// Additional cases for non-enum connector types
|
||||
case "YOUTUBE_VIDEO":
|
||||
return <IconBrandYoutube {...iconProps} />;
|
||||
|
|
|
|||
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!,
|
||||
},
|
||||
});
|
||||
|
|
@ -34,7 +34,8 @@ export type DocumentType =
|
|||
| "CONFLUENCE_CONNECTOR"
|
||||
| "CLICKUP_CONNECTOR"
|
||||
| "GOOGLE_CALENDAR_CONNECTOR"
|
||||
| "GOOGLE_GMAIL_CONNECTOR";
|
||||
| "GOOGLE_GMAIL_CONNECTOR"
|
||||
| "LUMA_CONNECTOR";
|
||||
|
||||
export function useDocumentByChunk() {
|
||||
const [document, setDocument] = useState<DocumentWithChunks | null>(null);
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ export type DocumentType =
|
|||
| "CLICKUP_CONNECTOR"
|
||||
| "GOOGLE_CALENDAR_CONNECTOR"
|
||||
| "GOOGLE_GMAIL_CONNECTOR"
|
||||
| "AIRTABLE_CONNECTOR";
|
||||
| "AIRTABLE_CONNECTOR"
|
||||
| "LUMA_CONNECTOR";
|
||||
|
||||
export interface UseDocumentsOptions {
|
||||
page?: number;
|
||||
|
|
|
|||
39
surfsense_web/hooks/use-media-query.ts
Normal file
39
surfsense_web/hooks/use-media-query.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
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;
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -52,6 +52,7 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
JIRA_BASE_URL: "",
|
||||
JIRA_EMAIL: "",
|
||||
JIRA_API_TOKEN: "",
|
||||
LUMA_API_KEY: "",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -78,6 +79,7 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
JIRA_BASE_URL: config.JIRA_BASE_URL || "",
|
||||
JIRA_EMAIL: config.JIRA_EMAIL || "",
|
||||
JIRA_API_TOKEN: config.JIRA_API_TOKEN || "",
|
||||
LUMA_API_KEY: config.LUMA_API_KEY || "",
|
||||
});
|
||||
if (currentConnector.connector_type === "GITHUB_CONNECTOR") {
|
||||
const savedRepos = config.repo_full_names || [];
|
||||
|
|
@ -303,6 +305,16 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
};
|
||||
}
|
||||
break;
|
||||
case "LUMA_CONNECTOR":
|
||||
if (formData.LUMA_API_KEY !== originalConfig.LUMA_API_KEY) {
|
||||
if (!formData.LUMA_API_KEY) {
|
||||
toast.error("Luma API Key cannot be empty.");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
newConfig = { LUMA_API_KEY: formData.LUMA_API_KEY };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (newConfig !== null) {
|
||||
|
|
@ -365,6 +377,8 @@ export function useConnectorEditPage(connectorId: number, searchSpaceId: string)
|
|||
editForm.setValue("JIRA_BASE_URL", newlySavedConfig.JIRA_BASE_URL || "");
|
||||
editForm.setValue("JIRA_EMAIL", newlySavedConfig.JIRA_EMAIL || "");
|
||||
editForm.setValue("JIRA_API_TOKEN", newlySavedConfig.JIRA_API_TOKEN || "");
|
||||
} else if (connector.connector_type === "LUMA_CONNECTOR") {
|
||||
editForm.setValue("LUMA_API_KEY", newlySavedConfig.LUMA_API_KEY || "");
|
||||
}
|
||||
}
|
||||
if (connector.connector_type === "GITHUB_CONNECTOR") {
|
||||
|
|
|
|||
214
surfsense_web/lib/auth-errors.ts
Normal file
214
surfsense_web/lib/auth-errors.ts
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
/**
|
||||
* Authentication error messages and handling utilities
|
||||
*/
|
||||
|
||||
interface AuthErrorMapping {
|
||||
[key: string]: {
|
||||
title: string;
|
||||
description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const AUTH_ERROR_MESSAGES: AuthErrorMapping = {
|
||||
// Common HTTP errors
|
||||
"401": {
|
||||
title: "Invalid credentials",
|
||||
description: "Please check your email and password",
|
||||
},
|
||||
"403": {
|
||||
title: "Access denied",
|
||||
description: "Your account may be suspended or restricted",
|
||||
},
|
||||
"404": {
|
||||
title: "Account not found",
|
||||
description: "No account exists with this email address",
|
||||
},
|
||||
"409": {
|
||||
title: "Account conflict",
|
||||
description: "An account with this email already exists",
|
||||
},
|
||||
"429": {
|
||||
title: "Too many attempts",
|
||||
description: "Please wait before trying again",
|
||||
},
|
||||
"500": {
|
||||
title: "Server error",
|
||||
description: "Something went wrong on our end. Please try again",
|
||||
},
|
||||
"503": {
|
||||
title: "Service unavailable",
|
||||
description: "Login service is temporarily down",
|
||||
},
|
||||
|
||||
// FastAPI specific errors
|
||||
LOGIN_BAD_CREDENTIALS: {
|
||||
title: "Invalid credentials",
|
||||
description: "The email or password you entered is incorrect",
|
||||
},
|
||||
LOGIN_USER_NOT_VERIFIED: {
|
||||
title: "Account not verified",
|
||||
description: "Please verify your email address before signing in",
|
||||
},
|
||||
USER_INACTIVE: {
|
||||
title: "Account inactive",
|
||||
description: "Your account has been deactivated. Contact support for assistance",
|
||||
},
|
||||
REGISTER_USER_ALREADY_EXISTS: {
|
||||
title: "Account already exists",
|
||||
description: "An account with this email address already exists",
|
||||
},
|
||||
REGISTER_INVALID_PASSWORD: {
|
||||
title: "Invalid password",
|
||||
description: "Password must meet security requirements",
|
||||
},
|
||||
|
||||
// OAuth errors
|
||||
access_denied: {
|
||||
title: "Access denied",
|
||||
description: "You denied access or cancelled the login process",
|
||||
},
|
||||
invalid_request: {
|
||||
title: "Invalid request",
|
||||
description: "The login request was malformed",
|
||||
},
|
||||
unauthorized_client: {
|
||||
title: "Authentication failed",
|
||||
description: "The application is not authorized to perform this action",
|
||||
},
|
||||
unsupported_response_type: {
|
||||
title: "Login method not supported",
|
||||
description: "This login method is not currently available",
|
||||
},
|
||||
invalid_scope: {
|
||||
title: "Invalid permissions",
|
||||
description: "The requested permissions are not valid",
|
||||
},
|
||||
server_error: {
|
||||
title: "Server error",
|
||||
description: "An error occurred on the authentication server",
|
||||
},
|
||||
temporarily_unavailable: {
|
||||
title: "Service unavailable",
|
||||
description: "Login is temporarily unavailable. Please try again later",
|
||||
},
|
||||
|
||||
// Network errors
|
||||
NETWORK_ERROR: {
|
||||
title: "Connection failed",
|
||||
description: "Please check your internet connection and try again",
|
||||
},
|
||||
TIMEOUT: {
|
||||
title: "Request timeout",
|
||||
description: "The login request took too long. Please try again",
|
||||
},
|
||||
|
||||
// Generic fallbacks
|
||||
UNKNOWN_ERROR: {
|
||||
title: "Login failed",
|
||||
description: "An unexpected error occurred. Please try again",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a user-friendly error message for authentication errors
|
||||
* @param errorCode - The error code or message from the API
|
||||
* @param returnTitle - Whether to return just the title or full description
|
||||
* @returns Formatted error message
|
||||
*/
|
||||
export function getAuthErrorMessage(errorCode: string, returnTitle: boolean = false): string {
|
||||
if (!errorCode) {
|
||||
const fallback = AUTH_ERROR_MESSAGES.UNKNOWN_ERROR;
|
||||
return returnTitle ? fallback.title : fallback.description || fallback.title;
|
||||
}
|
||||
|
||||
// Clean up the error code
|
||||
const cleanErrorCode = errorCode.trim().toUpperCase();
|
||||
|
||||
// Try exact match first
|
||||
let errorInfo = AUTH_ERROR_MESSAGES[cleanErrorCode] || AUTH_ERROR_MESSAGES[errorCode];
|
||||
|
||||
// Try partial matches for HTTP status codes
|
||||
if (!errorInfo) {
|
||||
const statusCodeMatch = errorCode.match(/(\d{3})/);
|
||||
if (statusCodeMatch) {
|
||||
errorInfo = AUTH_ERROR_MESSAGES[statusCodeMatch[1]];
|
||||
}
|
||||
}
|
||||
|
||||
// Try partial matches for common error patterns
|
||||
if (!errorInfo) {
|
||||
const patterns = [
|
||||
{ pattern: /credential|password|email/i, code: "LOGIN_BAD_CREDENTIALS" },
|
||||
{ pattern: /verify|verification/i, code: "LOGIN_USER_NOT_VERIFIED" },
|
||||
{ pattern: /inactive|disabled|suspended/i, code: "USER_INACTIVE" },
|
||||
{ pattern: /exists|duplicate/i, code: "REGISTER_USER_ALREADY_EXISTS" },
|
||||
{ pattern: /network|connection/i, code: "NETWORK_ERROR" },
|
||||
{ pattern: /timeout/i, code: "TIMEOUT" },
|
||||
{ pattern: /rate|limit|many/i, code: "429" },
|
||||
];
|
||||
|
||||
for (const { pattern, code } of patterns) {
|
||||
if (pattern.test(errorCode)) {
|
||||
errorInfo = AUTH_ERROR_MESSAGES[code];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to unknown error
|
||||
if (!errorInfo) {
|
||||
errorInfo = AUTH_ERROR_MESSAGES.UNKNOWN_ERROR;
|
||||
}
|
||||
|
||||
return returnTitle ? errorInfo.title : errorInfo.description || errorInfo.title;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get both title and description for an error
|
||||
* @param errorCode - The error code or message from the API
|
||||
* @returns Object with title and description
|
||||
*/
|
||||
export function getAuthErrorDetails(errorCode: string): { title: string; description: string } {
|
||||
const title = getAuthErrorMessage(errorCode, true);
|
||||
const description = getAuthErrorMessage(errorCode, false);
|
||||
|
||||
return { title, description };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is a network-related error
|
||||
* @param error - The error object or message
|
||||
* @returns True if it's a network error
|
||||
*/
|
||||
export function isNetworkError(error: unknown): boolean {
|
||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return /network|connection|fetch|cors/i.test(error);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error should trigger a retry action
|
||||
* @param errorCode - The error code or message
|
||||
* @returns True if retry is recommended
|
||||
*/
|
||||
export function shouldRetry(errorCode: string): boolean {
|
||||
const retryableCodes = [
|
||||
"500",
|
||||
"503",
|
||||
"429",
|
||||
"NETWORK_ERROR",
|
||||
"TIMEOUT",
|
||||
"server_error",
|
||||
"temporarily_unavailable",
|
||||
];
|
||||
|
||||
return retryableCodes.some(
|
||||
(code) => errorCode.includes(code) || errorCode.toUpperCase().includes(code)
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ export const getConnectorTypeDisplay = (type: string): string => {
|
|||
GOOGLE_CALENDAR_CONNECTOR: "Google Calendar",
|
||||
GOOGLE_GMAIL_CONNECTOR: "Google Gmail",
|
||||
AIRTABLE_CONNECTOR: "Airtable",
|
||||
LUMA_CONNECTOR: "Luma",
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -76,13 +87,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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
679
surfsense_web/pnpm-lock.yaml
generated
679
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
Loading…
Add table
Add a link
Reference in a new issue