diff --git a/surfsense_backend/alembic/versions/59_add_teams_connector_enums.py b/surfsense_backend/alembic/versions/59_add_teams_connector_enums.py index f13fbe9e5..d4f6629a7 100644 --- a/surfsense_backend/alembic/versions/59_add_teams_connector_enums.py +++ b/surfsense_backend/alembic/versions/59_add_teams_connector_enums.py @@ -86,7 +86,7 @@ def downgrade() -> None: "ELASTICSEARCH_CONNECTOR", "WEBCRAWLER_CONNECTOR", ) - + # All document values except TEAMS_CONNECTOR old_document_values = ( "EXTENSION", diff --git a/surfsense_backend/app/connectors/teams_connector.py b/surfsense_backend/app/connectors/teams_connector.py index 29c2db127..c639ab177 100644 --- a/surfsense_backend/app/connectors/teams_connector.py +++ b/surfsense_backend/app/connectors/teams_connector.py @@ -7,7 +7,7 @@ Supports OAuth-based authentication with token refresh. """ import logging -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any import httpx @@ -253,7 +253,9 @@ class TeamsConnector: access_token = await self._get_valid_token() async with httpx.AsyncClient() as client: - url = f"{self.GRAPH_API_BASE}/teams/{team_id}/channels/{channel_id}/messages" + url = ( + f"{self.GRAPH_API_BASE}/teams/{team_id}/channels/{channel_id}/messages" + ) # Note: The Graph API for channel messages doesn't support $filter parameter # We fetch all messages and filter them client-side @@ -270,34 +272,36 @@ class TeamsConnector: data = response.json() messages = data.get("value", []) - + # Filter messages by date if needed (client-side filtering) if start_date or end_date: # Make sure comparison dates are timezone-aware (UTC) if start_date and start_date.tzinfo is None: - start_date = start_date.replace(tzinfo=timezone.utc) + start_date = start_date.replace(tzinfo=UTC) if end_date and end_date.tzinfo is None: - end_date = end_date.replace(tzinfo=timezone.utc) - + end_date = end_date.replace(tzinfo=UTC) + filtered_messages = [] for message in messages: created_at_str = message.get("createdDateTime") if not created_at_str: continue - + # Parse the ISO 8601 datetime string (already timezone-aware) - created_at = datetime.fromisoformat(created_at_str.replace('Z', '+00:00')) - + created_at = datetime.fromisoformat( + created_at_str.replace("Z", "+00:00") + ) + # Check if message is within date range if start_date and created_at < start_date: continue if end_date and created_at > end_date: continue - + filtered_messages.append(message) - + return filtered_messages - + return messages async def get_message_replies( diff --git a/surfsense_backend/app/routes/search_source_connectors_routes.py b/surfsense_backend/app/routes/search_source_connectors_routes.py index c9831484d..06d75c7c9 100644 --- a/surfsense_backend/app/routes/search_source_connectors_routes.py +++ b/surfsense_backend/app/routes/search_source_connectors_routes.py @@ -543,7 +543,7 @@ async def index_connector_content( ), end_date: str = Query( None, - description="End date for indexing (YYYY-MM-DD format). If not provided, uses today's date", + description="End date for indexing (YYYY-MM-DD format). If not provided, uses today's date. For calendar connectors (Google Calendar, Luma), future dates can be selected to index upcoming events.", ), drive_items: GoogleDriveIndexRequest | None = Body( None, @@ -617,7 +617,19 @@ async def index_connector_content( else: indexing_from = start_date - indexing_to = end_date if end_date else today_str + # For calendar connectors, default to today but allow future dates if explicitly provided + if connector.connector_type in [ + SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR, + SearchSourceConnectorType.LUMA_CONNECTOR, + ]: + # Default to today if no end_date provided (users can manually select future dates) + if end_date is None: + indexing_to = today_str + else: + indexing_to = end_date + else: + # For non-calendar connectors, cap at today + indexing_to = end_date if end_date else today_str if connector.connector_type == SearchSourceConnectorType.SLACK_CONNECTOR: from app.tasks.celery_tasks.connector_tasks import ( @@ -871,9 +883,10 @@ async def index_connector_content( ) from e -async def update_connector_last_indexed(session: AsyncSession, connector_id: int): +async def _update_connector_timestamp_by_id(session: AsyncSession, connector_id: int): """ - Update the last_indexed_at timestamp for a connector. + Update the last_indexed_at timestamp for a connector by its ID. + Internal helper function for routes. Args: session: Database session @@ -948,7 +961,7 @@ async def run_slack_indexing( # Only update last_indexed_at if indexing was successful (either new docs or updated docs) if documents_processed > 0: - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) logger.info( f"Slack indexing completed successfully: {documents_processed} documents processed" ) @@ -1010,7 +1023,7 @@ async def run_notion_indexing( # Only update last_indexed_at if indexing was successful (either new docs or updated docs) if documents_processed > 0: - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) logger.info( f"Notion indexing completed successfully: {documents_processed} documents processed" ) @@ -1070,7 +1083,7 @@ async def run_github_indexing( f"GitHub indexing successful for connector {connector_id}. Indexed {indexed_count} documents." ) # Update the last indexed timestamp only on success - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) await session.commit() # Commit timestamp update except Exception as e: await session.rollback() @@ -1129,7 +1142,7 @@ async def run_linear_indexing( f"Linear indexing successful for connector {connector_id}. Indexed {indexed_count} documents." ) # Update the last indexed timestamp only on success - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) await session.commit() # Commit timestamp update except Exception as e: await session.rollback() @@ -1190,7 +1203,7 @@ async def run_discord_indexing( # Only update last_indexed_at if indexing was successful (either new docs or updated docs) if documents_processed > 0: - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) logger.info( f"Discord indexing completed successfully: {documents_processed} documents processed" ) @@ -1252,7 +1265,7 @@ async def run_teams_indexing( ) # Update last_indexed_at after successful indexing (even if 0 new docs - they were checked) - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) logger.info( f"Teams indexing completed successfully: {documents_processed} documents processed. {error_or_warning or ''}" ) @@ -1308,7 +1321,7 @@ async def run_jira_indexing( f"Jira indexing successful for connector {connector_id}. Indexed {indexed_count} documents." ) # Update the last indexed timestamp only on success - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) await session.commit() # Commit timestamp update except Exception as e: logger.error( @@ -1368,7 +1381,7 @@ async def run_confluence_indexing( f"Confluence indexing successful for connector {connector_id}. Indexed {indexed_count} documents." ) # Update the last indexed timestamp only on success - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) await session.commit() # Commit timestamp update except Exception as e: logger.error( @@ -1426,7 +1439,7 @@ async def run_clickup_indexing( f"ClickUp indexing successful for connector {connector_id}. Indexed {indexed_count} tasks." ) # Update the last indexed timestamp only on success - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) await session.commit() # Commit timestamp update except Exception as e: logger.error( @@ -1484,7 +1497,7 @@ async def run_airtable_indexing( f"Airtable indexing successful for connector {connector_id}. Indexed {indexed_count} records." ) # Update the last indexed timestamp only on success - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) await session.commit() # Commit timestamp update except Exception as e: logger.error( @@ -1544,7 +1557,7 @@ async def run_google_calendar_indexing( f"Google Calendar indexing successful for connector {connector_id}. Indexed {indexed_count} documents." ) # Update the last indexed timestamp only on success - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) await session.commit() # Commit timestamp update except Exception as e: logger.error( @@ -1611,7 +1624,7 @@ async def run_google_gmail_indexing( f"Google Gmail indexing successful for connector {connector_id}. Indexed {indexed_count} documents." ) # Update the last indexed timestamp only on success - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) await session.commit() # Commit timestamp update except Exception as e: logger.error( @@ -1695,7 +1708,7 @@ async def run_google_drive_indexing( f"Google Drive indexing successful for connector {connector_id}. Indexed {total_indexed} documents from {len(items.folders)} folder(s) and {len(items.files)} file(s)." ) # Update the last indexed timestamp only on full success - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) await session.commit() # Commit timestamp update except Exception as e: logger.error( @@ -1755,7 +1768,7 @@ async def run_luma_indexing( # Only update last_indexed_at if indexing was successful (either new docs or updated docs) if documents_processed > 0: - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) logger.info( f"Luma indexing completed successfully: {documents_processed} documents processed" ) @@ -1815,7 +1828,7 @@ async def run_elasticsearch_indexing( f"Elasticsearch indexing successful for connector {connector_id}. Indexed {indexed_count} documents." ) # Update the last indexed timestamp only on success - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) await session.commit() except Exception as e: await session.rollback() @@ -1874,7 +1887,7 @@ async def run_web_page_indexing( # Only update last_indexed_at if indexing was successful (either new docs or updated docs) if documents_processed > 0: - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) logger.info( f"Web page indexing completed successfully: {documents_processed} documents processed" ) @@ -1947,7 +1960,7 @@ async def run_bookstack_indexing( f"BookStack indexing successful for connector {connector_id}. Indexed {indexed_count} documents." ) # Update the last indexed timestamp only on success - await update_connector_last_indexed(session, connector_id) + await _update_connector_timestamp_by_id(session, connector_id) await session.commit() # Commit timestamp update except Exception as e: logger.error( diff --git a/surfsense_backend/app/routes/teams_add_connector_route.py b/surfsense_backend/app/routes/teams_add_connector_route.py index ce014be0d..9ce84e171 100644 --- a/surfsense_backend/app/routes/teams_add_connector_route.py +++ b/surfsense_backend/app/routes/teams_add_connector_route.py @@ -343,8 +343,12 @@ async def teams_callback( except IntegrityError as e: await session.rollback() - logger.error("Database integrity error creating Teams connector: %s", str(e)) - redirect_url = f"{config.NEXT_FRONTEND_URL}/dashboard?error=connector_creation_failed" + logger.error( + "Database integrity error creating Teams connector: %s", str(e) + ) + redirect_url = ( + f"{config.NEXT_FRONTEND_URL}/dashboard?error=connector_creation_failed" + ) return RedirectResponse(url=redirect_url) except HTTPException: diff --git a/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py b/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py index 499f01d66..b8c0e564d 100644 --- a/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/google_calendar_indexer.py @@ -45,8 +45,9 @@ async def index_google_calendar_events( connector_id: ID of the Google Calendar connector search_space_id: ID of the search space to store documents in user_id: User ID - start_date: Start date for indexing (YYYY-MM-DD format) - end_date: End date for indexing (YYYY-MM-DD format) + start_date: Start date for indexing (YYYY-MM-DD format). Can be in the past or future. + end_date: End date for indexing (YYYY-MM-DD format). Can be in the future to index upcoming events. + Defaults to today if not provided. update_last_indexed: Whether to update the last_indexed_at timestamp (default: True) Returns: @@ -165,8 +166,10 @@ async def index_google_calendar_events( end_date = None # Calculate date range + # For calendar connectors, allow future dates to index upcoming events if start_date is None or end_date is None: # Fall back to calculating dates based on last_indexed_at + # Default to today (users can manually select future dates if needed) calculated_end_date = datetime.now() # Use last_indexed_at as start date if available, otherwise use 30 days ago @@ -178,19 +181,13 @@ async def index_google_calendar_events( else connector.last_indexed_at ) - # Check if last_indexed_at is in the future or after end_date - if last_indexed_naive > calculated_end_date: - logger.warning( - f"Last indexed date ({last_indexed_naive.strftime('%Y-%m-%d')}) is in the future. Using 30 days ago instead." - ) - calculated_start_date = calculated_end_date - timedelta(days=30) - else: - calculated_start_date = last_indexed_naive - logger.info( - f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date" - ) + # Allow future dates - use last_indexed_at as start date + calculated_start_date = last_indexed_naive + logger.info( + f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date" + ) else: - calculated_start_date = calculated_end_date - timedelta( + calculated_start_date = datetime.now() - timedelta( days=30 ) # Use 30 days as default for calendar events logger.info( @@ -205,7 +202,7 @@ async def index_google_calendar_events( end_date if end_date else calculated_end_date.strftime("%Y-%m-%d") ) else: - # Use provided dates + # Use provided dates (including future dates) start_date_str = start_date end_date_str = end_date diff --git a/surfsense_backend/app/tasks/connector_indexers/luma_indexer.py b/surfsense_backend/app/tasks/connector_indexers/luma_indexer.py index 4d5ddc47c..91f81ac20 100644 --- a/surfsense_backend/app/tasks/connector_indexers/luma_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/luma_indexer.py @@ -45,8 +45,9 @@ async def index_luma_events( connector_id: ID of the Luma connector search_space_id: ID of the search space to store documents in user_id: User ID - start_date: Start date for indexing (YYYY-MM-DD format) - end_date: End date for indexing (YYYY-MM-DD format) + start_date: Start date for indexing (YYYY-MM-DD format). Can be in the past or future. + end_date: End date for indexing (YYYY-MM-DD format). Can be in the future to index upcoming events. + Defaults to today if not provided. update_last_indexed: Whether to update the last_indexed_at timestamp (default: True) Returns: @@ -116,8 +117,10 @@ async def index_luma_events( luma_client = LumaConnector(api_key=api_key) # Calculate date range + # For calendar connectors, allow future dates to index upcoming events if start_date is None or end_date is None: # Fall back to calculating dates based on last_indexed_at + # Default to today (users can manually select future dates if needed) calculated_end_date = datetime.now() # Use last_indexed_at as start date if available, otherwise use 30 days ago @@ -129,19 +132,13 @@ async def index_luma_events( else connector.last_indexed_at ) - # Check if last_indexed_at is in the future or after end_date - if last_indexed_naive > calculated_end_date: - logger.warning( - f"Last indexed date ({last_indexed_naive.strftime('%Y-%m-%d')}) is in the future. Using 30 days ago instead." - ) - calculated_start_date = calculated_end_date - timedelta(days=30) - else: - calculated_start_date = last_indexed_naive - logger.info( - f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date" - ) + # Allow future dates - use last_indexed_at as start date + calculated_start_date = last_indexed_naive + logger.info( + f"Using last_indexed_at ({calculated_start_date.strftime('%Y-%m-%d')}) as start date" + ) else: - calculated_start_date = calculated_end_date - timedelta(days=30) + calculated_start_date = datetime.now() - timedelta(days=30) logger.info( f"No last_indexed_at found, using {calculated_start_date.strftime('%Y-%m-%d')} (30 days ago) as start date" ) @@ -154,7 +151,7 @@ async def index_luma_events( end_date if end_date else calculated_end_date.strftime("%Y-%m-%d") ) else: - # Use provided dates + # Use provided dates (including future dates) start_date_str = start_date end_date_str = end_date diff --git a/surfsense_backend/app/tasks/connector_indexers/teams_indexer.py b/surfsense_backend/app/tasks/connector_indexers/teams_indexer.py index c1e778768..2709adaf1 100644 --- a/surfsense_backend/app/tasks/connector_indexers/teams_indexer.py +++ b/surfsense_backend/app/tasks/connector_indexers/teams_indexer.py @@ -2,6 +2,8 @@ Microsoft Teams connector indexer. """ +from datetime import UTC + from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncSession @@ -165,16 +167,20 @@ async def index_teams_messages( ) # Convert date strings to datetime objects for filtering - from datetime import datetime, timezone + from datetime import datetime start_datetime = None end_datetime = None if start_date_str: # Parse as naive datetime and make it timezone-aware (UTC) - start_datetime = datetime.strptime(start_date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc) + start_datetime = datetime.strptime(start_date_str, "%Y-%m-%d").replace( + tzinfo=UTC + ) if end_date_str: # Parse as naive datetime, set to end of day, and make it timezone-aware (UTC) - end_datetime = datetime.strptime(end_date_str, "%Y-%m-%d").replace(hour=23, minute=59, second=59, tzinfo=timezone.utc) + end_datetime = datetime.strptime(end_date_str, "%Y-%m-%d").replace( + hour=23, minute=59, second=59, tzinfo=UTC + ) # Process each team for team in teams: @@ -312,8 +318,10 @@ async def index_teams_messages( chunks = await create_document_chunks( combined_document_string ) - doc_embedding = config.embedding_model_instance.embed( - combined_document_string + doc_embedding = ( + config.embedding_model_instance.embed( + combined_document_string + ) ) # Update existing document @@ -335,11 +343,14 @@ async def index_teams_messages( # Delete old chunks and add new ones existing_document.chunks = chunks - existing_document.updated_at = get_current_timestamp() + existing_document.updated_at = ( + get_current_timestamp() + ) documents_indexed += 1 logger.info( - "Successfully updated Teams message %s", message_id + "Successfully updated Teams message %s", + message_id, ) continue diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index d51b30bd7..dd284307f 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -1,3 +1,4 @@ +import logging import uuid from fastapi import Depends, Request, Response @@ -12,7 +13,17 @@ from fastapi_users.db import SQLAlchemyUserDatabase from pydantic import BaseModel from app.config import config -from app.db import User, get_user_db +from app.db import ( + SearchSpace, + SearchSpaceMembership, + SearchSpaceRole, + User, + async_session_maker, + get_default_roles_config, + get_user_db, +) + +logger = logging.getLogger(__name__) class BearerResponse(BaseModel): @@ -36,7 +47,59 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): verification_token_secret = SECRET async def on_after_register(self, user: User, request: Request | None = None): - print(f"User {user.id} has registered.") + """ + Called after a user registers. Creates a default search space for the user + so they can start chatting immediately without manual setup. + """ + logger.info(f"User {user.id} has registered. Creating default search space...") + + try: + async with async_session_maker() as session: + # Create default search space + default_search_space = SearchSpace( + name="My Search Space", + description="Your personal search space", + user_id=user.id, + ) + session.add(default_search_space) + await session.flush() # Get the search space ID + + # Create default roles + default_roles = get_default_roles_config() + owner_role_id = None + + for role_config in default_roles: + db_role = SearchSpaceRole( + name=role_config["name"], + description=role_config["description"], + permissions=role_config["permissions"], + is_default=role_config["is_default"], + is_system_role=role_config["is_system_role"], + search_space_id=default_search_space.id, + ) + session.add(db_role) + await session.flush() + + if role_config["name"] == "Owner": + owner_role_id = db_role.id + + # Create owner membership + owner_membership = SearchSpaceMembership( + user_id=user.id, + search_space_id=default_search_space.id, + role_id=owner_role_id, + is_owner=True, + ) + session.add(owner_membership) + + await session.commit() + logger.info( + f"Created default search space (ID: {default_search_space.id}) for user {user.id}" + ) + except Exception as e: + logger.error( + f"Failed to create default search space for user {user.id}: {e}" + ) async def on_after_forgot_password( self, user: User, token: str, request: Request | None = None diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index c78cc7762..7b1bb61b0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -5,7 +5,7 @@ import { Loader2 } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import type React from "react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { myAccessAtom } from "@/atoms/members/members-query.atoms"; import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms"; @@ -17,22 +17,18 @@ import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-quer import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; +import { LayoutDataProvider } from "@/components/layout"; import { OnboardingTour } from "@/components/onboarding-tour"; -import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; -import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"; export function DashboardClientLayout({ children, searchSpaceId, - navSecondary, - navMain, }: { children: React.ReactNode; searchSpaceId: string; - navSecondary: any[]; - navMain: any[]; + navSecondary?: any[]; + navMain?: any[]; }) { const t = useTranslations("dashboard"); const router = useRouter(); @@ -59,50 +55,15 @@ export function DashboardClientLayout({ const [isAutoConfiguring, setIsAutoConfiguring] = useState(false); const hasAttemptedAutoConfig = useRef(false); - // Skip onboarding check if we're already on the onboarding page const isOnboardingPage = pathname?.includes("/onboard"); - - // Only owners should see onboarding - invited members use existing config const isOwner = access?.is_owner ?? false; - // Translate navigation items - const tNavMenu = useTranslations("nav_menu"); - const translatedNavMain = useMemo(() => { - return navMain.map((item) => ({ - ...item, - title: tNavMenu(item.title.toLowerCase().replace(/ /g, "_")), - items: item.items?.map((subItem: any) => ({ - ...subItem, - title: tNavMenu(subItem.title.toLowerCase().replace(/ /g, "_")), - })), - })); - }, [navMain, tNavMenu]); - - const translatedNavSecondary = useMemo(() => { - return navSecondary.map((item) => ({ - ...item, - title: item.title === "All Search Spaces" ? tNavMenu("all_search_spaces") : item.title, - })); - }, [navSecondary, tNavMenu]); - - const [open, setOpen] = useState(() => { - try { - const match = document.cookie.match(/(?:^|; )sidebar_state=([^;]+)/); - if (match) return match[1] === "true"; - } catch { - // ignore - } - return true; - }); - useEffect(() => { - // Skip check if already on onboarding page if (isOnboardingPage) { setHasCheckedOnboarding(true); return; } - // Wait for all data to load if ( !loading && !accessLoading && @@ -112,19 +73,16 @@ export function DashboardClientLayout({ ) { const onboardingComplete = isOnboardingComplete(); - // If onboarding is complete, nothing to do if (onboardingComplete) { setHasCheckedOnboarding(true); return; } - // Only handle onboarding for owners if (!isOwner) { setHasCheckedOnboarding(true); return; } - // If global configs available, auto-configure without going to onboard page if (globalConfigs.length > 0 && !hasAttemptedAutoConfig.current) { hasAttemptedAutoConfig.current = true; setIsAutoConfiguring(true); @@ -149,7 +107,6 @@ export function DashboardClientLayout({ setHasCheckedOnboarding(true); } catch (error) { console.error("Auto-configuration failed:", error); - // Fall back to onboard page router.push(`/dashboard/${searchSpaceId}/onboard`); } finally { setIsAutoConfiguring(false); @@ -160,7 +117,6 @@ export function DashboardClientLayout({ return; } - // No global configs - redirect to onboard page router.push(`/dashboard/${searchSpaceId}/onboard`); setHasCheckedOnboarding(true); } @@ -180,7 +136,6 @@ export function DashboardClientLayout({ refetchPreferences, ]); - // Synchronize active search space and chat IDs with URL useEffect(() => { const activeSeacrhSpaceId = typeof search_space_id === "string" @@ -192,7 +147,6 @@ export function DashboardClientLayout({ setActiveSearchSpaceIdState(activeSeacrhSpaceId); }, [search_space_id, setActiveSearchSpaceIdState]); - // Show loading screen while checking onboarding status or auto-configuring if ( (!hasCheckedOnboarding && (loading || accessLoading || globalConfigsLoading) && @@ -220,7 +174,6 @@ export function DashboardClientLayout({ ); } - // Show error screen if there's an error loading preferences (but not on onboarding page) if (error && !hasCheckedOnboarding && !isOnboardingPage) { return (
@@ -244,33 +197,13 @@ export function DashboardClientLayout({ return ( - - {/* Use AppSidebarProvider which fetches user, search space, and recent chats */} - - -
-
-
-
- -
- - -
-
-
- -
-
-
-
{children}
-
-
-
+ } + languageSwitcher={} + > + {children} +
); } diff --git a/surfsense_web/app/dashboard/page.tsx b/surfsense_web/app/dashboard/page.tsx index fa611aabf..ad1c6ad9d 100644 --- a/surfsense_web/app/dashboard/page.tsx +++ b/surfsense_web/app/dashboard/page.tsx @@ -7,6 +7,7 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; +import { useEffect } from "react"; import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; @@ -129,6 +130,7 @@ const ErrorScreen = ({ message }: { message: string }) => { const DashboardPage = () => { const t = useTranslations("dashboard"); const tCommon = useTranslations("common"); + const router = useRouter(); // Animation variants const containerVariants: Variants = { @@ -164,6 +166,15 @@ const DashboardPage = () => { const { data: user, isPending: isLoadingUser, error: userError } = useAtomValue(currentUserAtom); + // Auto-redirect to chat for users with exactly 1 search space + useEffect(() => { + if (loading) return; + + if (searchSpaces.length === 1) { + router.replace(`/dashboard/${searchSpaces[0].id}/new-chat`); + } + }, [loading, searchSpaces, router]); + // Create user object for UserDropdown const customUser = { name: user?.email ? user.email.split("@")[0] : "User", @@ -173,7 +184,8 @@ const DashboardPage = () => { avatar: "/icon-128.svg", // Default avatar }; - if (loading) return ; + // Show loading while loading or auto-redirecting (single search space) + if (loading || (searchSpaces.length === 1 && !error)) return ; if (error) return ; const handleDeleteSearchSpace = async (id: number) => { diff --git a/surfsense_web/app/sitemap.ts b/surfsense_web/app/sitemap.ts index 4be30eb82..0e8441aa5 100644 --- a/surfsense_web/app/sitemap.ts +++ b/surfsense_web/app/sitemap.ts @@ -14,159 +14,165 @@ export default function sitemap(): MetadataRoute.Sitemap { { url: "https://www.surfsense.com/", lastModified, - changeFrequency: "yearly", + changeFrequency: "daily", priority: 1, }, { url: "https://www.surfsense.com/contact", lastModified, - changeFrequency: "yearly", - priority: 1, + changeFrequency: "daily", + priority: 0.9, }, { url: "https://www.surfsense.com/pricing", lastModified, - changeFrequency: "yearly", + changeFrequency: "daily", priority: 0.9, }, { url: "https://www.surfsense.com/privacy", lastModified, - changeFrequency: "monthly", + changeFrequency: "daily", priority: 0.9, }, { url: "https://www.surfsense.com/terms", lastModified, - changeFrequency: "monthly", + changeFrequency: "daily", priority: 0.9, }, // Documentation pages { url: "https://www.surfsense.com/docs", lastModified, - changeFrequency: "weekly", - priority: 0.9, + changeFrequency: "daily", + priority: 1, }, { url: "https://www.surfsense.com/docs/installation", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.9, }, { url: "https://www.surfsense.com/docs/docker-installation", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.9, }, { url: "https://www.surfsense.com/docs/manual-installation", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.9, }, // Connector documentation { url: "https://www.surfsense.com/docs/connectors/airtable", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/bookstack", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/circleback", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/clickup", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/confluence", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/discord", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/elasticsearch", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/github", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/gmail", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/google-calendar", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/google-drive", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/jira", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/linear", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/luma", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", + priority: 0.8, + }, + { + url: "https://www.surfsense.com/docs/connectors/microsoft-teams", + lastModified, + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/notion", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/slack", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, { url: "https://www.surfsense.com/docs/connectors/web-crawler", lastModified, - changeFrequency: "weekly", + changeFrequency: "daily", priority: 0.8, }, ]; diff --git a/surfsense_web/components/TokenHandler.tsx b/surfsense_web/components/TokenHandler.tsx index 42905ac0d..b4ca36298 100644 --- a/surfsense_web/components/TokenHandler.tsx +++ b/surfsense_web/components/TokenHandler.tsx @@ -1,6 +1,6 @@ "use client"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useSearchParams } from "next/navigation"; import { useEffect } from "react"; import { getAndClearRedirectPath, setBearerToken } from "@/lib/auth-utils"; import { trackLoginSuccess } from "@/lib/posthog/events"; @@ -25,7 +25,6 @@ const TokenHandler = ({ tokenParamName = "token", storageKey = "surfsense_bearer_token", }: TokenHandlerProps) => { - const router = useRouter(); const searchParams = useSearchParams(); useEffect(() => { @@ -58,14 +57,14 @@ const TokenHandler = ({ const finalRedirectPath = savedRedirectPath || redirectPath; // Redirect to the appropriate path - router.push(finalRedirectPath); + window.location.href = finalRedirectPath; } catch (error) { console.error("Error storing token in localStorage:", error); // Even if there's an error, try to redirect to the default path - router.push(redirectPath); + window.location.href = redirectPath; } } - }, [searchParams, tokenParamName, storageKey, redirectPath, router]); + }, [searchParams, tokenParamName, storageKey, redirectPath]); return (
diff --git a/surfsense_web/components/UserDropdown.tsx b/surfsense_web/components/UserDropdown.tsx index 966193c7f..a7f9c89ac 100644 --- a/surfsense_web/components/UserDropdown.tsx +++ b/surfsense_web/components/UserDropdown.tsx @@ -34,14 +34,14 @@ export function UserDropdown({ if (typeof window !== "undefined") { localStorage.removeItem("surfsense_bearer_token"); - router.push("/"); + window.location.href = "/"; } } catch (error) { console.error("Error during logout:", error); // Optionally, provide user feedback if (typeof window !== "undefined") { alert("Logout failed. Please try again."); - router.push("/"); + window.location.href = "/"; } } }; diff --git a/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx b/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx index bbb2ea482..48dc2a6c2 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/components/date-range-selector.tsx @@ -1,6 +1,6 @@ "use client"; -import { format, subDays, subYears } from "date-fns"; +import { addDays, format, subDays, subYears } from "date-fns"; import { Calendar as CalendarIcon } from "lucide-react"; import type { FC } from "react"; import { Button } from "@/components/ui/button"; @@ -14,6 +14,7 @@ interface DateRangeSelectorProps { endDate: Date | undefined; onStartDateChange: (date: Date | undefined) => void; onEndDateChange: (date: Date | undefined) => void; + allowFutureDates?: boolean; // Allow future dates for calendar connectors } export const DateRangeSelector: FC = ({ @@ -21,6 +22,7 @@ export const DateRangeSelector: FC = ({ endDate, onStartDateChange, onEndDateChange, + allowFutureDates = false, }) => { const handleLast30Days = () => { const today = new Date(); @@ -28,6 +30,12 @@ export const DateRangeSelector: FC = ({ onEndDateChange(today); }; + const handleNext30Days = () => { + const today = new Date(); + onStartDateChange(today); + onEndDateChange(addDays(today, 30)); + }; + const handleLastYear = () => { const today = new Date(); onStartDateChange(subYears(today, 1)); @@ -43,8 +51,9 @@ export const DateRangeSelector: FC = ({

Select Date Range

- Choose how far back you want to sync your data. You can always re-index later with different - dates. + {allowFutureDates + ? "Choose the date range to sync your data. You can select future dates to index upcoming events." + : "Choose how far back you want to sync your data. You can always re-index later with different dates."}

@@ -72,7 +81,7 @@ export const DateRangeSelector: FC = ({ mode="single" selected={startDate} onSelect={onStartDateChange} - disabled={(date) => date > new Date()} + disabled={allowFutureDates ? false : (date) => date > new Date()} /> @@ -102,7 +111,11 @@ export const DateRangeSelector: FC = ({ mode="single" selected={endDate} onSelect={onEndDateChange} - disabled={(date) => date > new Date() || (startDate ? date < startDate : false)} + disabled={ + allowFutureDates + ? (date) => (startDate ? date < startDate : false) + : (date) => date > new Date() || (startDate ? date < startDate : false) + } /> @@ -129,6 +142,17 @@ export const DateRangeSelector: FC = ({ > Last 30 Days + {allowFutureDates && ( + + )} + + + + + + {/* All Chats Sidebar */} + + + {/* All Notes Sidebar */} + + + {/* Delete Note Dialog */} + + + + + + {t("delete_note")} + + + {t("delete_note_confirm")} {noteToDelete?.name}?{" "} + {t("action_cannot_undone")} + + + + + + + + + + ); +} diff --git a/surfsense_web/components/layout/providers/index.ts b/surfsense_web/components/layout/providers/index.ts new file mode 100644 index 000000000..61ea094de --- /dev/null +++ b/surfsense_web/components/layout/providers/index.ts @@ -0,0 +1 @@ +export { LayoutDataProvider } from "./LayoutDataProvider"; diff --git a/surfsense_web/components/layout/types/layout.types.ts b/surfsense_web/components/layout/types/layout.types.ts new file mode 100644 index 000000000..b11619c60 --- /dev/null +++ b/surfsense_web/components/layout/types/layout.types.ts @@ -0,0 +1,139 @@ +import type { LucideIcon } from "lucide-react"; + +export interface Workspace { + id: number; + name: string; + description?: string | null; + isOwner: boolean; + memberCount: number; +} + +export interface User { + email: string; + name?: string; +} + +export interface NavItem { + title: string; + url: string; + icon: LucideIcon; + isActive?: boolean; + badge?: string | number; +} + +export interface ChatItem { + id: number; + name: string; + url: string; + isActive?: boolean; +} + +export interface NoteItem { + id: number; + name: string; + url: string; + isActive?: boolean; + isReindexing?: boolean; +} + +export interface PageUsage { + pagesUsed: number; + pagesLimit: number; +} + +export interface IconRailProps { + workspaces: Workspace[]; + activeWorkspaceId: number | null; + onWorkspaceSelect: (id: number) => void; + onAddWorkspace: () => void; + className?: string; +} + +export interface SidebarHeaderProps { + workspace: Workspace | null; + onSettings?: () => void; +} + +export interface SidebarSectionProps { + title: string; + defaultOpen?: boolean; + children: React.ReactNode; + action?: React.ReactNode; +} + +export interface NavSectionProps { + items: NavItem[]; + onItemClick?: (item: NavItem) => void; +} + +export interface ChatsSectionProps { + chats: ChatItem[]; + activeChatId?: number | null; + onChatSelect: (chat: ChatItem) => void; + onChatDelete?: (chat: ChatItem) => void; + onViewAllChats?: () => void; + searchSpaceId?: string; +} + +export interface NotesSectionProps { + notes: NoteItem[]; + activeNoteId?: number | null; + onNoteSelect: (note: NoteItem) => void; + onNoteDelete?: (note: NoteItem) => void; + onAddNote?: () => void; + onViewAllNotes?: () => void; + searchSpaceId?: string; +} + +export interface PageUsageDisplayProps { + pagesUsed: number; + pagesLimit: number; +} + +export interface SidebarUserProfileProps { + user: User; + searchSpaceId?: string; + onSettings?: () => void; + onInviteMembers?: () => void; + onSwitchWorkspace?: () => void; + onToggleTheme?: () => void; + onLogout?: () => void; + theme?: string; +} + +export interface SidebarProps { + workspace: Workspace | null; + searchSpaceId?: string; + navItems: NavItem[]; + chats: ChatItem[]; + activeChatId?: number | null; + onNewChat: () => void; + onChatSelect: (chat: ChatItem) => void; + onChatDelete?: (chat: ChatItem) => void; + onViewAllChats?: () => void; + notes: NoteItem[]; + activeNoteId?: number | null; + onNoteSelect: (note: NoteItem) => void; + onNoteDelete?: (note: NoteItem) => void; + onAddNote?: () => void; + onViewAllNotes?: () => void; + user: User; + theme?: string; + onSettings?: () => void; + onInviteMembers?: () => void; + onSwitchWorkspace?: () => void; + onToggleTheme?: () => void; + onLogout?: () => void; + pageUsage?: PageUsage; + className?: string; +} + +export interface LayoutShellProps { + workspaces: Workspace[]; + activeWorkspaceId: number | null; + onWorkspaceSelect: (id: number) => void; + onAddWorkspace: () => void; + sidebarProps: Omit; + children: React.ReactNode; + className?: string; +} diff --git a/surfsense_web/components/layout/ui/header/Header.tsx b/surfsense_web/components/layout/ui/header/Header.tsx new file mode 100644 index 000000000..a03761ef5 --- /dev/null +++ b/surfsense_web/components/layout/ui/header/Header.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { Moon, Sun } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +interface HeaderProps { + breadcrumb?: React.ReactNode; + languageSwitcher?: React.ReactNode; + theme?: string; + onToggleTheme?: () => void; + mobileMenuTrigger?: React.ReactNode; +} + +export function Header({ + breadcrumb, + languageSwitcher, + theme, + onToggleTheme, + mobileMenuTrigger, +}: HeaderProps) { + return ( +
+ {/* Left side - Mobile menu trigger + Breadcrumb */} +
+ {mobileMenuTrigger} + {breadcrumb} +
+ + {/* Right side - Actions */} +
+ {/* Theme toggle */} + {onToggleTheme && ( + + + + + {theme === "dark" ? "Light mode" : "Dark mode"} + + )} + + {languageSwitcher} +
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/header/index.ts b/surfsense_web/components/layout/ui/header/index.ts new file mode 100644 index 000000000..c940126c9 --- /dev/null +++ b/surfsense_web/components/layout/ui/header/index.ts @@ -0,0 +1 @@ +export { Header } from "./Header"; diff --git a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx new file mode 100644 index 000000000..0d6b39cdc --- /dev/null +++ b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { Workspace } from "../../types/layout.types"; +import { WorkspaceAvatar } from "./WorkspaceAvatar"; + +interface IconRailProps { + workspaces: Workspace[]; + activeWorkspaceId: number | null; + onWorkspaceSelect: (id: number) => void; + onAddWorkspace: () => void; + className?: string; +} + +export function IconRail({ + workspaces, + activeWorkspaceId, + onWorkspaceSelect, + onAddWorkspace, + className, +}: IconRailProps) { + return ( +
+ +
+ {workspaces.map((workspace) => ( + onWorkspaceSelect(workspace.id)} + size="md" + /> + ))} + + + + + + + Add workspace + + +
+
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/icon-rail/NavIcon.tsx b/surfsense_web/components/layout/ui/icon-rail/NavIcon.tsx new file mode 100644 index 000000000..3efb48748 --- /dev/null +++ b/surfsense_web/components/layout/ui/icon-rail/NavIcon.tsx @@ -0,0 +1,34 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +interface NavIconProps { + icon: LucideIcon; + label: string; + isActive?: boolean; + onClick?: () => void; +} + +export function NavIcon({ icon: Icon, label, isActive, onClick }: NavIconProps) { + return ( + + + + + + {label} + + + ); +} diff --git a/surfsense_web/components/layout/ui/icon-rail/WorkspaceAvatar.tsx b/surfsense_web/components/layout/ui/icon-rail/WorkspaceAvatar.tsx new file mode 100644 index 000000000..1c4798d2a --- /dev/null +++ b/surfsense_web/components/layout/ui/icon-rail/WorkspaceAvatar.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +interface WorkspaceAvatarProps { + name: string; + isActive?: boolean; + onClick?: () => void; + size?: "sm" | "md"; +} + +/** + * Generates a consistent color based on workspace name + */ +function stringToColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const colors = [ + "#6366f1", // indigo + "#22c55e", // green + "#f59e0b", // amber + "#ef4444", // red + "#8b5cf6", // violet + "#06b6d4", // cyan + "#ec4899", // pink + "#14b8a6", // teal + ]; + return colors[Math.abs(hash) % colors.length]; +} + +/** + * Gets initials from workspace name (max 2 chars) + */ +function getInitials(name: string): string { + const words = name.trim().split(/\s+/); + if (words.length >= 2) { + return (words[0][0] + words[1][0]).toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); +} + +export function WorkspaceAvatar({ name, isActive, onClick, size = "md" }: WorkspaceAvatarProps) { + const bgColor = stringToColor(name); + const initials = getInitials(name); + const sizeClasses = size === "sm" ? "h-8 w-8 text-xs" : "h-10 w-10 text-sm"; + + return ( + + + + + + {name} + + + ); +} diff --git a/surfsense_web/components/layout/ui/icon-rail/index.ts b/surfsense_web/components/layout/ui/icon-rail/index.ts new file mode 100644 index 000000000..0e7e8cd29 --- /dev/null +++ b/surfsense_web/components/layout/ui/icon-rail/index.ts @@ -0,0 +1,3 @@ +export { IconRail } from "./IconRail"; +export { NavIcon } from "./NavIcon"; +export { WorkspaceAvatar } from "./WorkspaceAvatar"; diff --git a/surfsense_web/components/layout/ui/index.ts b/surfsense_web/components/layout/ui/index.ts new file mode 100644 index 000000000..74b1e9240 --- /dev/null +++ b/surfsense_web/components/layout/ui/index.ts @@ -0,0 +1,16 @@ +export { Header } from "./header"; +export { IconRail, NavIcon, WorkspaceAvatar } from "./icon-rail"; +export { LayoutShell } from "./shell"; +export { + ChatListItem, + MobileSidebar, + MobileSidebarTrigger, + NavSection, + NoteListItem, + PageUsageDisplay, + Sidebar, + SidebarCollapseButton, + SidebarHeader, + SidebarSection, + SidebarUserProfile, +} from "./sidebar"; diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx new file mode 100644 index 000000000..0d7b24113 --- /dev/null +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -0,0 +1,203 @@ +"use client"; + +import { useState } from "react"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { cn } from "@/lib/utils"; +import { useSidebarState } from "../../hooks"; +import type { + ChatItem, + NavItem, + NoteItem, + PageUsage, + User, + Workspace, +} from "../../types/layout.types"; +import { Header } from "../header"; +import { IconRail } from "../icon-rail"; +import { MobileSidebar, MobileSidebarTrigger, Sidebar } from "../sidebar"; + +interface LayoutShellProps { + workspaces: Workspace[]; + activeWorkspaceId: number | null; + onWorkspaceSelect: (id: number) => void; + onAddWorkspace: () => void; + workspace: Workspace | null; + navItems: NavItem[]; + onNavItemClick?: (item: NavItem) => void; + chats: ChatItem[]; + activeChatId?: number | null; + onNewChat: () => void; + onChatSelect: (chat: ChatItem) => void; + onChatDelete?: (chat: ChatItem) => void; + onViewAllChats?: () => void; + notes: NoteItem[]; + activeNoteId?: number | null; + onNoteSelect: (note: NoteItem) => void; + onNoteDelete?: (note: NoteItem) => void; + onAddNote?: () => void; + onViewAllNotes?: () => void; + user: User; + onSettings?: () => void; + onInviteMembers?: () => void; + onSeeAllWorkspaces?: () => void; + onLogout?: () => void; + pageUsage?: PageUsage; + breadcrumb?: React.ReactNode; + languageSwitcher?: React.ReactNode; + theme?: string; + onToggleTheme?: () => void; + defaultCollapsed?: boolean; + isChatPage?: boolean; + children: React.ReactNode; + className?: string; +} + +export function LayoutShell({ + workspaces, + activeWorkspaceId, + onWorkspaceSelect, + onAddWorkspace, + workspace, + navItems, + onNavItemClick, + chats, + activeChatId, + onNewChat, + onChatSelect, + onChatDelete, + onViewAllChats, + notes, + activeNoteId, + onNoteSelect, + onNoteDelete, + onAddNote, + onViewAllNotes, + user, + onSettings, + onInviteMembers, + onSeeAllWorkspaces, + onLogout, + pageUsage, + breadcrumb, + languageSwitcher, + theme, + onToggleTheme, + defaultCollapsed = false, + isChatPage = false, + children, + className, +}: LayoutShellProps) { + const isMobile = useIsMobile(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const { isCollapsed, toggleCollapsed } = useSidebarState(defaultCollapsed); + + // Mobile layout + if (isMobile) { + return ( + +
+
setMobileMenuOpen(true)} />} + /> + + + +
+ {children} +
+
+
+ ); + } + + // Desktop layout + return ( + +
+
+ +
+ +
+ + +
+
+ +
+ {children} +
+
+
+
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/shell/index.ts b/surfsense_web/components/layout/ui/shell/index.ts new file mode 100644 index 000000000..d7d96a574 --- /dev/null +++ b/surfsense_web/components/layout/ui/shell/index.ts @@ -0,0 +1 @@ +export { LayoutShell } from "./LayoutShell"; diff --git a/surfsense_web/components/sidebar/all-chats-sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx similarity index 100% rename from surfsense_web/components/sidebar/all-chats-sidebar.tsx rename to surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx diff --git a/surfsense_web/components/sidebar/all-notes-sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx similarity index 100% rename from surfsense_web/components/sidebar/all-notes-sidebar.tsx rename to surfsense_web/components/layout/ui/sidebar/AllNotesSidebar.tsx diff --git a/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx new file mode 100644 index 000000000..7f5ede04c --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/ChatListItem.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { MessageSquare, MoreHorizontal } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +interface ChatListItemProps { + name: string; + isActive?: boolean; + onClick?: () => void; + onDelete?: () => void; +} + +export function ChatListItem({ name, isActive, onClick, onDelete }: ChatListItemProps) { + const t = useTranslations("sidebar"); + + return ( +
+ + + {/* Actions dropdown */} +
+ + + + + + { + e.stopPropagation(); + onDelete?.(); + }} + className="text-destructive focus:text-destructive" + > + {t("delete")} + + + +
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx new file mode 100644 index 000000000..8429d6671 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { Menu } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; +import type { + ChatItem, + NavItem, + NoteItem, + PageUsage, + User, + Workspace, +} from "../../types/layout.types"; +import { IconRail } from "../icon-rail"; +import { Sidebar } from "./Sidebar"; + +interface MobileSidebarProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + workspaces: Workspace[]; + activeWorkspaceId: number | null; + onWorkspaceSelect: (id: number) => void; + onAddWorkspace: () => void; + workspace: Workspace | null; + navItems: NavItem[]; + onNavItemClick?: (item: NavItem) => void; + chats: ChatItem[]; + activeChatId?: number | null; + onNewChat: () => void; + onChatSelect: (chat: ChatItem) => void; + onChatDelete?: (chat: ChatItem) => void; + onViewAllChats?: () => void; + notes: NoteItem[]; + activeNoteId?: number | null; + onNoteSelect: (note: NoteItem) => void; + onNoteDelete?: (note: NoteItem) => void; + onAddNote?: () => void; + onViewAllNotes?: () => void; + user: User; + onSettings?: () => void; + onInviteMembers?: () => void; + onSeeAllWorkspaces?: () => void; + onLogout?: () => void; + pageUsage?: PageUsage; +} + +export function MobileSidebarTrigger({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +export function MobileSidebar({ + isOpen, + onOpenChange, + workspaces, + activeWorkspaceId, + onWorkspaceSelect, + onAddWorkspace, + workspace, + navItems, + onNavItemClick, + chats, + activeChatId, + onNewChat, + onChatSelect, + onChatDelete, + onViewAllChats, + notes, + activeNoteId, + onNoteSelect, + onNoteDelete, + onAddNote, + onViewAllNotes, + user, + onSettings, + onInviteMembers, + onSeeAllWorkspaces, + onLogout, + pageUsage, +}: MobileSidebarProps) { + const handleWorkspaceSelect = (id: number) => { + onWorkspaceSelect(id); + }; + + const handleNavItemClick = (item: NavItem) => { + onNavItemClick?.(item); + onOpenChange(false); + }; + + const handleChatSelect = (chat: ChatItem) => { + onChatSelect(chat); + onOpenChange(false); + }; + + const handleNoteSelect = (note: NoteItem) => { + onNoteSelect(note); + onOpenChange(false); + }; + + return ( + + + Navigation + +
+ + + +
+ +
+ { + onNewChat(); + onOpenChange(false); + }} + onChatSelect={handleChatSelect} + onChatDelete={onChatDelete} + onViewAllChats={onViewAllChats} + notes={notes} + activeNoteId={activeNoteId} + onNoteSelect={handleNoteSelect} + onNoteDelete={onNoteDelete} + onAddNote={onAddNote} + onViewAllNotes={onViewAllNotes} + user={user} + onSettings={onSettings} + onInviteMembers={onInviteMembers} + onSeeAllWorkspaces={onSeeAllWorkspaces} + onLogout={onLogout} + pageUsage={pageUsage} + className="w-full border-none" + /> +
+
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/NavSection.tsx b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx new file mode 100644 index 000000000..7b694055b --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/NavSection.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { NavItem } from "../../types/layout.types"; + +interface NavSectionProps { + items: NavItem[]; + onItemClick?: (item: NavItem) => void; + isCollapsed?: boolean; +} + +export function NavSection({ items, onItemClick, isCollapsed = false }: NavSectionProps) { + return ( +
+ {items.map((item) => { + const Icon = item.icon; + + // Add data-joyride for onboarding tour + const joyrideAttr = + item.title === "Documents" || item.title.toLowerCase().includes("documents") + ? { "data-joyride": "documents-sidebar" } + : {}; + + if (isCollapsed) { + return ( + + + + + + {item.title} + {item.badge && ` (${item.badge})`} + + + ); + } + + return ( + + ); + })} +
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx b/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx new file mode 100644 index 000000000..0491ebcca --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/NoteListItem.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { FileText, Loader2, MoreHorizontal } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; + +interface NoteListItemProps { + name: string; + isActive?: boolean; + isReindexing?: boolean; + onClick?: () => void; + onDelete?: () => void; +} + +export function NoteListItem({ + name, + isActive, + isReindexing, + onClick, + onDelete, +}: NoteListItemProps) { + const t = useTranslations("sidebar"); + + return ( +
+ + + {/* Actions dropdown */} +
+ + + + + + { + e.stopPropagation(); + onDelete?.(); + }} + className="text-destructive focus:text-destructive" + > + {t("delete")} + + + +
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx b/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx new file mode 100644 index 000000000..85abae19b --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/PageUsageDisplay.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Mail } from "lucide-react"; +import { Progress } from "@/components/ui/progress"; + +interface PageUsageDisplayProps { + pagesUsed: number; + pagesLimit: number; +} + +export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) { + const usagePercentage = (pagesUsed / pagesLimit) * 100; + + return ( +
+
+
+ + {pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages + + {usagePercentage.toFixed(0)}% +
+ + + + Contact to increase limits + +
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx new file mode 100644 index 000000000..5031b08b5 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -0,0 +1,294 @@ +"use client"; + +import { FileText, FolderOpen, MessageSquare, PenSquare, Plus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { + ChatItem, + NavItem, + NoteItem, + PageUsage, + User, + Workspace, +} from "../../types/layout.types"; +import { ChatListItem } from "./ChatListItem"; +import { NavSection } from "./NavSection"; +import { NoteListItem } from "./NoteListItem"; +import { PageUsageDisplay } from "./PageUsageDisplay"; +import { SidebarCollapseButton } from "./SidebarCollapseButton"; +import { SidebarHeader } from "./SidebarHeader"; +import { SidebarSection } from "./SidebarSection"; +import { SidebarUserProfile } from "./SidebarUserProfile"; + +interface SidebarProps { + workspace: Workspace | null; + isCollapsed?: boolean; + onToggleCollapse?: () => void; + navItems: NavItem[]; + onNavItemClick?: (item: NavItem) => void; + chats: ChatItem[]; + activeChatId?: number | null; + onNewChat: () => void; + onChatSelect: (chat: ChatItem) => void; + onChatDelete?: (chat: ChatItem) => void; + onViewAllChats?: () => void; + notes: NoteItem[]; + activeNoteId?: number | null; + onNoteSelect: (note: NoteItem) => void; + onNoteDelete?: (note: NoteItem) => void; + onAddNote?: () => void; + onViewAllNotes?: () => void; + user: User; + onSettings?: () => void; + onInviteMembers?: () => void; + onSeeAllWorkspaces?: () => void; + onLogout?: () => void; + pageUsage?: PageUsage; + className?: string; +} + +export function Sidebar({ + workspace, + isCollapsed = false, + onToggleCollapse, + navItems, + onNavItemClick, + chats, + activeChatId, + onNewChat, + onChatSelect, + onChatDelete, + onViewAllChats, + notes, + activeNoteId, + onNoteSelect, + onNoteDelete, + onAddNote, + onViewAllNotes, + user, + onSettings, + onInviteMembers, + onSeeAllWorkspaces, + onLogout, + pageUsage, + className, +}: SidebarProps) { + const t = useTranslations("sidebar"); + + return ( +
+ {/* Header - workspace name or collapse button when collapsed */} + {isCollapsed ? ( +
+ {})} + /> +
+ ) : ( +
+ +
+ {})} + /> +
+
+ )} + + {/* New chat button */} +
+ {isCollapsed ? ( + + + + + {t("new_chat")} + + ) : ( + + )} +
+ + {/* Platform navigation */} + {navItems.length > 0 && ( + + )} + + {/* Scrollable content */} + + {isCollapsed ? ( +
+ {chats.length > 0 && ( + + + + + + {t("recent_chats")} ({chats.length}) + + + )} + {notes.length > 0 && ( + + + + + + {t("notes")} ({notes.length}) + + + )} +
+ ) : ( +
+ 0 ? ( + + + + + {t("view_all_chats")} + + ) : undefined + } + > + {chats.length > 0 ? ( +
+ {chats.map((chat) => ( + onChatSelect(chat)} + onDelete={() => onChatDelete?.(chat)} + /> + ))} +
+ ) : ( +

{t("no_recent_chats")}

+ )} +
+ + 0 ? ( + + + + + {t("view_all_notes")} + + ) : undefined + } + persistentAction={ + onAddNote && notes.length > 0 ? ( + + + + + {t("add_note")} + + ) : undefined + } + > + {notes.length > 0 ? ( +
+ {notes.map((note) => ( + onNoteSelect(note)} + onDelete={() => onNoteDelete?.(note)} + /> + ))} +
+ ) : onAddNote ? ( + + ) : ( +

{t("no_notes")}

+ )} +
+
+ )} +
+ + {/* Footer */} +
+ {pageUsage && !isCollapsed && ( + + )} + + +
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx new file mode 100644 index 000000000..3eaa87070 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/SidebarCollapseButton.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { PanelLeft, PanelLeftClose } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +interface SidebarCollapseButtonProps { + isCollapsed: boolean; + onToggle: () => void; +} + +export function SidebarCollapseButton({ isCollapsed, onToggle }: SidebarCollapseButtonProps) { + const t = useTranslations("sidebar"); + + return ( + + + + + + {isCollapsed ? `${t("expand_sidebar")} (⌘B)` : `${t("collapse_sidebar")} (⌘B)`} + + + ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx new file mode 100644 index 000000000..cf15a367e --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/SidebarHeader.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { ChevronsUpDown, LayoutGrid, Settings, UserPlus } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/lib/utils"; +import type { Workspace } from "../../types/layout.types"; + +interface SidebarHeaderProps { + workspace: Workspace | null; + isCollapsed?: boolean; + onSettings?: () => void; + onInviteMembers?: () => void; + onSeeAllWorkspaces?: () => void; + className?: string; +} + +export function SidebarHeader({ + workspace, + isCollapsed, + onSettings, + onInviteMembers, + onSeeAllWorkspaces, + className, +}: SidebarHeaderProps) { + const t = useTranslations("sidebar"); + + return ( +
+ + + + + + + + {t("invite_members")} + + + + + {t("workspace_settings")} + + + + + {t("see_all_workspaces")} + + + +
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx new file mode 100644 index 000000000..4d161e3fa --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSection.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { ChevronRight } from "lucide-react"; +import { useState } from "react"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; + +interface SidebarSectionProps { + title: string; + defaultOpen?: boolean; + children: React.ReactNode; + action?: React.ReactNode; + persistentAction?: React.ReactNode; +} + +export function SidebarSection({ + title, + defaultOpen = true, + children, + action, + persistentAction, +}: SidebarSectionProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( + +
+ + + {title} + + + {/* Action button - visible on hover (always visible on mobile) */} + {action && ( +
+ {action} +
+ )} + + {/* Persistent action - always visible */} + {persistentAction && ( +
{persistentAction}
+ )} +
+ + +
{children}
+
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx new file mode 100644 index 000000000..29b35b9a9 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/SidebarUserProfile.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { ChevronUp, LogOut } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import type { User } from "../../types/layout.types"; + +interface SidebarUserProfileProps { + user: User; + onLogout?: () => void; + isCollapsed?: boolean; +} + +/** + * Generates a consistent color based on email + */ +function stringToColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + } + const colors = [ + "#6366f1", + "#8b5cf6", + "#a855f7", + "#d946ef", + "#ec4899", + "#f43f5e", + "#ef4444", + "#f97316", + "#eab308", + "#84cc16", + "#22c55e", + "#14b8a6", + "#06b6d4", + "#0ea5e9", + "#3b82f6", + ]; + return colors[Math.abs(hash) % colors.length]; +} + +/** + * Gets initials from email + */ +function getInitials(email: string): string { + const name = email.split("@")[0]; + const parts = name.split(/[._-]/); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); +} + +export function SidebarUserProfile({ + user, + onLogout, + isCollapsed = false, +}: SidebarUserProfileProps) { + const t = useTranslations("sidebar"); + const bgColor = stringToColor(user.email); + const initials = getInitials(user.email); + const displayName = user.name || user.email.split("@")[0]; + + // Collapsed view - just show avatar with dropdown + if (isCollapsed) { + return ( +
+ + + + + + + + {displayName} + + + + +
+
+ {initials} +
+
+

{displayName}

+

{user.email}

+
+
+
+ + + + + + {t("logout")} + +
+
+
+ ); + } + + // Expanded view + return ( +
+ + + + + + + +
+
+ {initials} +
+
+

{displayName}

+

{user.email}

+
+
+
+ + + + + + {t("logout")} + +
+
+
+ ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts new file mode 100644 index 000000000..d98b45ca5 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -0,0 +1,12 @@ +export { AllChatsSidebar } from "./AllChatsSidebar"; +export { AllNotesSidebar } from "./AllNotesSidebar"; +export { ChatListItem } from "./ChatListItem"; +export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; +export { NavSection } from "./NavSection"; +export { NoteListItem } from "./NoteListItem"; +export { PageUsageDisplay } from "./PageUsageDisplay"; +export { Sidebar } from "./Sidebar"; +export { SidebarCollapseButton } from "./SidebarCollapseButton"; +export { SidebarHeader } from "./SidebarHeader"; +export { SidebarSection } from "./SidebarSection"; +export { SidebarUserProfile } from "./SidebarUserProfile"; diff --git a/surfsense_web/components/onboarding-tour.tsx b/surfsense_web/components/onboarding-tour.tsx index 958bb43b0..717a27607 100644 --- a/surfsense_web/components/onboarding-tour.tsx +++ b/surfsense_web/components/onboarding-tour.tsx @@ -407,7 +407,7 @@ export function OnboardingTour() { // Fetch threads data const { data: threadsData } = useQuery({ - queryKey: ["threads", searchSpaceId], + queryKey: ["threads", searchSpaceId, { limit: 1 }], queryFn: () => fetchThreads(Number(searchSpaceId), 1), // Only need to check if any exist enabled: !!searchSpaceId, }); diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx deleted file mode 100644 index f5146c427..000000000 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ /dev/null @@ -1,383 +0,0 @@ -"use client"; - -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAtomValue, useSetAtom } from "jotai"; -import { Trash2 } from "lucide-react"; -import { useParams, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useMemo, useState } from "react"; -import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { AppSidebar } from "@/components/sidebar/app-sidebar"; -import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { notesApiService } from "@/lib/apis/notes-api.service"; -import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { deleteThread, fetchThreads } from "@/lib/chat/thread-persistence"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; - -interface AppSidebarProviderProps { - searchSpaceId: string; - navSecondary: { - title: string; - url: string; - icon: string; - }[]; - navMain: { - title: string; - url: string; - icon: string; - isActive?: boolean; - items?: { - title: string; - url: string; - }[]; - }[]; -} - -export function AppSidebarProvider({ - searchSpaceId, - navSecondary, - navMain, -}: AppSidebarProviderProps) { - const t = useTranslations("dashboard"); - const tCommon = useTranslations("common"); - const router = useRouter(); - const params = useParams(); - const queryClient = useQueryClient(); - - // Get current chat ID from URL params - const currentChatId = params?.chat_id - ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id) - : null; - const [isDeletingThread, setIsDeletingThread] = useState(false); - - // Editor state for handling unsaved changes - const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom); - const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom); - - // Fetch new chat threads - const { - data: threadsData, - error: threadError, - refetch: refetchThreads, - } = useQuery({ - queryKey: ["threads", searchSpaceId], - queryFn: () => fetchThreads(Number(searchSpaceId), 4), - enabled: !!searchSpaceId, - }); - - const { - data: searchSpace, - isLoading: isLoadingSearchSpace, - error: searchSpaceError, - } = useQuery({ - queryKey: cacheKeys.searchSpaces.detail(searchSpaceId), - queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }), - enabled: !!searchSpaceId, - }); - - const { data: user } = useAtomValue(currentUserAtom); - - // Fetch notes - const { data: notesData, refetch: refetchNotes } = useQuery({ - queryKey: ["notes", searchSpaceId], - queryFn: () => - notesApiService.getNotes({ - search_space_id: Number(searchSpaceId), - page_size: 4, // Get 4 notes for compact sidebar - }), - enabled: !!searchSpaceId, - }); - - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [threadToDelete, setThreadToDelete] = useState<{ id: number; name: string } | null>(null); - const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false); - const [noteToDelete, setNoteToDelete] = useState<{ - id: number; - name: string; - search_space_id: number; - } | null>(null); - const [isDeletingNote, setIsDeletingNote] = useState(false); - - // Transform threads to the format expected by AppSidebar - const recentChats = useMemo(() => { - if (!threadsData?.threads) return []; - - // Threads are already sorted by updated_at desc from the API - return threadsData.threads.map((thread) => ({ - name: thread.title || `Chat ${thread.id}`, - url: `/dashboard/${searchSpaceId}/new-chat/${thread.id}`, - icon: "MessageCircleMore", - id: thread.id, - search_space_id: Number(searchSpaceId), - actions: [ - { - name: "Delete", - icon: "Trash2", - onClick: () => { - setThreadToDelete({ - id: thread.id, - name: thread.title || `Chat ${thread.id}`, - }); - setShowDeleteDialog(true); - }, - }, - ], - })); - }, [threadsData, searchSpaceId]); - - // Handle delete thread - const handleDeleteThread = useCallback(async () => { - if (!threadToDelete) return; - - setIsDeletingThread(true); - try { - await deleteThread(threadToDelete.id); - // Invalidate threads query to refresh the list - queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); - // Only navigate to new-chat if the deleted chat is currently open - if (currentChatId === threadToDelete.id) { - router.push(`/dashboard/${searchSpaceId}/new-chat`); - } - } catch (error) { - console.error("Error deleting thread:", error); - } finally { - setIsDeletingThread(false); - setShowDeleteDialog(false); - setThreadToDelete(null); - } - }, [threadToDelete, queryClient, searchSpaceId, router, currentChatId]); - - // Handle delete note with confirmation - const handleDeleteNote = useCallback(async () => { - if (!noteToDelete) return; - - setIsDeletingNote(true); - try { - await notesApiService.deleteNote({ - search_space_id: noteToDelete.search_space_id, - note_id: noteToDelete.id, - }); - refetchNotes(); - } catch (error) { - console.error("Error deleting note:", error); - } finally { - setIsDeletingNote(false); - setShowDeleteNoteDialog(false); - setNoteToDelete(null); - } - }, [noteToDelete, refetchNotes]); - - // Memoized fallback chats - const fallbackChats = useMemo(() => { - if (threadError) { - return [ - { - name: t("error_loading_chats"), - url: "#", - icon: "AlertCircle", - id: 0, - search_space_id: Number(searchSpaceId), - actions: [ - { - name: tCommon("retry"), - icon: "RefreshCw", - onClick: () => refetchThreads(), - }, - ], - }, - ]; - } - - return []; - }, [threadError, searchSpaceId, refetchThreads, t, tCommon]); - - // Use fallback chats if there's an error or no chats - const displayChats = recentChats.length > 0 ? recentChats : fallbackChats; - - // Transform notes to the format expected by NavNotes - const recentNotes = useMemo(() => { - if (!notesData?.items) return []; - - // Sort notes by updated_at (most recent first), fallback to created_at if updated_at is null - const sortedNotes = [...notesData.items].sort((a, b) => { - const dateA = a.updated_at - ? new Date(a.updated_at).getTime() - : new Date(a.created_at).getTime(); - const dateB = b.updated_at - ? new Date(b.updated_at).getTime() - : new Date(b.created_at).getTime(); - return dateB - dateA; // Descending order (most recent first) - }); - - // Limit to 4 notes for compact sidebar - return sortedNotes.slice(0, 4).map((note) => ({ - name: note.title, - url: `/dashboard/${note.search_space_id}/editor/${note.id}`, - icon: "FileText", - id: note.id, - search_space_id: note.search_space_id, - actions: [ - { - name: "Delete", - icon: "Trash2", - onClick: () => { - setNoteToDelete({ - id: note.id, - name: note.title, - search_space_id: note.search_space_id, - }); - setShowDeleteNoteDialog(true); - }, - }, - ], - })); - }, [notesData]); - - // Handle add note - check for unsaved changes first - const handleAddNote = useCallback(() => { - const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`; - - if (hasUnsavedEditorChanges) { - // Set pending navigation - the editor will show the unsaved changes dialog - setPendingNavigation(newNoteUrl); - } else { - // No unsaved changes, navigate directly - router.push(newNoteUrl); - } - }, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]); - - // Memoized updated navSecondary - const updatedNavSecondary = useMemo(() => { - const updated = [...navSecondary]; - if (updated.length > 0) { - updated[0] = { - ...updated[0], - title: - searchSpace?.name || - (isLoadingSearchSpace - ? tCommon("loading") - : searchSpaceError - ? t("error_loading_space") - : t("unknown_search_space")), - }; - } - return updated; - }, [navSecondary, searchSpace?.name, isLoadingSearchSpace, searchSpaceError, t, tCommon]); - - // Prepare page usage data - const pageUsage = user - ? { - pagesUsed: user.pages_used, - pagesLimit: user.pages_limit, - } - : undefined; - - return ( - <> - - - {/* Delete Confirmation Dialog */} - - - - - - {t("delete_chat")} - - - {t("delete_chat_confirm")} {threadToDelete?.name} - ? {t("action_cannot_undone")} - - - - - - - - - - {/* Delete Note Confirmation Dialog */} - - - - - - {t("delete_note")} - - - {t("delete_note_confirm")} {noteToDelete?.name}?{" "} - {t("action_cannot_undone")} - - - - - - - - - - ); -} diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx deleted file mode 100644 index 97d7fa9dd..000000000 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ /dev/null @@ -1,473 +0,0 @@ -"use client"; - -import { useAtomValue } from "jotai"; -import { - AlertCircle, - ArrowLeftRight, - BookOpen, - Cable, - ChevronsUpDown, - Database, - ExternalLink, - FileStack, - FileText, - Info, - LogOut, - Logs, - type LucideIcon, - MessageCircle, - MessageCircleMore, - MoonIcon, - Podcast, - RefreshCw, - Settings2, - SquareLibrary, - SquareTerminal, - SunIcon, - Trash2, - Undo2, - UserPlus, - Users, -} from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useTheme } from "next-themes"; -import { memo, useEffect, useMemo, useState } from "react"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { resetUser, trackLogout } from "@/lib/posthog/events"; - -/** - * Generates a consistent color based on a string (email) - */ -function stringToColor(str: string): string { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - const colors = [ - "#6366f1", // indigo - "#8b5cf6", // violet - "#a855f7", // purple - "#d946ef", // fuchsia - "#ec4899", // pink - "#f43f5e", // rose - "#ef4444", // red - "#f97316", // orange - "#eab308", // yellow - "#84cc16", // lime - "#22c55e", // green - "#14b8a6", // teal - "#06b6d4", // cyan - "#0ea5e9", // sky - "#3b82f6", // blue - ]; - return colors[Math.abs(hash) % colors.length]; -} - -/** - * Gets initials from an email address - */ -function getInitials(email: string): string { - const name = email.split("@")[0]; - const parts = name.split(/[._-]/); - if (parts.length >= 2) { - return (parts[0][0] + parts[1][0]).toUpperCase(); - } - return name.slice(0, 2).toUpperCase(); -} - -/** - * Dynamic avatar component that generates an SVG based on email - */ -function UserAvatar({ email, size = 32 }: { email: string; size?: number }) { - const bgColor = stringToColor(email); - const initials = getInitials(email); - - return ( - - Avatar for {email} - - - {initials} - - - ); -} - -import { NavChats } from "@/components/sidebar/nav-chats"; -import { NavMain } from "@/components/sidebar/nav-main"; -import { NavNotes } from "@/components/sidebar/nav-notes"; -import { NavSecondary } from "@/components/sidebar/nav-secondary"; -import { PageUsageDisplay } from "@/components/sidebar/page-usage-display"; -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar"; - -// Map of icon names to their components -export const iconMap: Record = { - BookOpen, - Cable, - Database, - FileStack, - Undo2, - MessageCircleMore, - Settings2, - SquareLibrary, - FileText, - SquareTerminal, - AlertCircle, - Info, - ExternalLink, - Trash2, - Podcast, - Users, - RefreshCw, - MessageCircle, - Logs, -}; - -const defaultData = { - user: { - name: "Surf", - email: "m@example.com", - avatar: "/icon-128.svg", - }, - navMain: [ - { - title: "Chat", - url: "#", - icon: "SquareTerminal", - isActive: true, - items: [], - }, - { - title: "Sources", - url: "#", - icon: "Database", - items: [ - { - title: "Manage Documents", - url: "#", - }, - { - title: "Manage Connectors", - url: "#", - }, - ], - }, - ], - navSecondary: [ - { - title: "SEARCH SPACE", - url: "#", - icon: "LifeBuoy", - }, - ], - RecentChats: [ - { - name: "Design Engineering", - url: "#", - icon: "MessageCircleMore", - id: 1001, - }, - { - name: "Sales & Marketing", - url: "#", - icon: "MessageCircleMore", - id: 1002, - }, - { - name: "Travel", - url: "#", - icon: "MessageCircleMore", - id: 1003, - }, - ], - RecentNotes: [ - { - name: "Meeting Notes", - url: "#", - icon: "FileText", - id: 2001, - }, - { - name: "Project Ideas", - url: "#", - icon: "FileText", - id: 2002, - }, - ], -}; - -interface AppSidebarProps extends React.ComponentProps { - searchSpaceId?: string; - navMain?: { - title: string; - url: string; - icon: string; - isActive?: boolean; - items?: { - title: string; - url: string; - }[]; - }[]; - navSecondary?: { - title: string; - url: string; - icon: string; - }[]; - RecentChats?: { - name: string; - url: string; - icon: string; - id?: number; - search_space_id?: number; - actions?: { - name: string; - icon: string; - onClick: () => void; - }[]; - }[]; - RecentNotes?: { - name: string; - url: string; - icon: string; - id?: number; - search_space_id?: number; - actions?: { - name: string; - icon: string; - onClick: () => void; - }[]; - }[]; - user?: { - name: string; - email: string; - avatar: string; - }; - pageUsage?: { - pagesUsed: number; - pagesLimit: number; - }; - onAddNote?: () => void; -} - -// Memoized AppSidebar component for better performance -export const AppSidebar = memo(function AppSidebar({ - searchSpaceId, - navMain = defaultData.navMain, - navSecondary = defaultData.navSecondary, - RecentChats = defaultData.RecentChats, - RecentNotes = defaultData.RecentNotes, - pageUsage, - onAddNote, - ...props -}: AppSidebarProps) { - const router = useRouter(); - const { theme, setTheme } = useTheme(); - const { data: user, isPending: isLoadingUser } = useAtomValue(currentUserAtom); - const [isClient, setIsClient] = useState(false); - - useEffect(() => { - setIsClient(true); - }, []); - - // Process navMain to resolve icon names to components - const processedNavMain = useMemo(() => { - return navMain.map((item) => ({ - ...item, - icon: iconMap[item.icon] || SquareTerminal, - })); - }, [navMain]); - - // Process navSecondary to resolve icon names to components - const processedNavSecondary = useMemo(() => { - return navSecondary.map((item) => ({ - ...item, - icon: iconMap[item.icon] || Undo2, - })); - }, [navSecondary]); - - // Process RecentChats to resolve icon names to components - const processedRecentChats = useMemo(() => { - return ( - RecentChats?.map((item) => ({ - ...item, - icon: iconMap[item.icon] || MessageCircleMore, - })) || [] - ); - }, [RecentChats]); - - // Process RecentNotes to resolve icon names to components - const processedRecentNotes = useMemo(() => { - return ( - RecentNotes?.map((item) => ({ - ...item, - icon: iconMap[item.icon] || FileText, - })) || [] - ); - }, [RecentNotes]); - - // Get user display name from email - const userDisplayName = user?.email ? user.email.split("@")[0] : "User"; - const userEmail = user?.email || (isLoadingUser ? "Loading..." : "Unknown"); - - const handleLogout = () => { - try { - // Track logout event and reset PostHog identity - trackLogout(); - resetUser(); - - if (typeof window !== "undefined") { - localStorage.removeItem("surfsense_bearer_token"); - router.push("/"); - } - } catch (error) { - console.error("Error during logout:", error); - router.push("/"); - } - }; - - return ( - - - - - - - -
- {user?.email ? ( - - ) : ( -
- )} -
-
- {userDisplayName} - {userEmail} -
- - - - - -
-
- {user?.email ? ( - - ) : ( -
- )} -
-
- {userDisplayName} - {userEmail} -
-
- - - - {searchSpaceId && ( - <> - router.push(`/dashboard/${searchSpaceId}/settings`)} - > - - Settings - - router.push(`/dashboard/${searchSpaceId}/team`)} - > - - Invite members - - - )} - router.push("/dashboard")}> - - Switch workspace - - - - - {isClient && ( - setTheme(theme === "dark" ? "light" : "dark")}> - {theme === "dark" ? ( - - ) : ( - - )} - {theme === "dark" ? "Light mode" : "Dark mode"} - - )} - - - - - Logout - - - - - - - - - - - - - - - - {pageUsage && ( - - )} - - - - ); -}); diff --git a/surfsense_web/components/sidebar/nav-chats.tsx b/surfsense_web/components/sidebar/nav-chats.tsx deleted file mode 100644 index ba0004fc8..000000000 --- a/surfsense_web/components/sidebar/nav-chats.tsx +++ /dev/null @@ -1,237 +0,0 @@ -"use client"; - -import { - ChevronRight, - FolderOpen, - Loader2, - type LucideIcon, - MessageCircleMore, - MoreHorizontal, - RefreshCw, - Trash2, -} from "lucide-react"; -import { usePathname, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar"; -import { cn } from "@/lib/utils"; -import { AllChatsSidebar } from "./all-chats-sidebar"; - -interface ChatAction { - name: string; - icon: string; - onClick: () => void; -} - -interface ChatItem { - name: string; - url: string; - icon: LucideIcon; - id?: number; - search_space_id?: number; - actions?: ChatAction[]; -} - -interface NavChatsProps { - chats: ChatItem[]; - defaultOpen?: boolean; - searchSpaceId?: string; -} - -// Map of icon names to their components -const actionIconMap: Record = { - MessageCircleMore, - Trash2, - MoreHorizontal, - RefreshCw, -}; - -export function NavChats({ chats, defaultOpen = true, searchSpaceId }: NavChatsProps) { - const t = useTranslations("sidebar"); - const router = useRouter(); - const pathname = usePathname(); - const { setOpenMobile } = useSidebar(); - const [isDeleting, setIsDeleting] = useState(null); - const [isOpen, setIsOpen] = useState(defaultOpen); - const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false); - - // Handle chat deletion with loading state - const handleDeleteChat = useCallback(async (chatId: number, deleteAction: () => void) => { - setIsDeleting(chatId); - try { - await deleteAction(); - } finally { - setIsDeleting(null); - } - }, []); - - // Handle chat navigation - const handleChatClick = useCallback( - (url: string) => { - router.push(url); - }, - [router] - ); - - return ( - - -
- - - - {t("recent_chats") || "Recent Chats"} - - - - {/* Action buttons - always visible on hover */} -
- {searchSpaceId && chats.length > 0 && ( - - )} -
-
- - - {chats.length > 0 ? ( - - - {chats.map((chat) => { - const isDeletingChat = isDeleting === chat.id; - const isActive = pathname === chat.url; - - return ( - - {/* Main navigation button */} - handleChatClick(chat.url)} - disabled={isDeletingChat} - className={cn( - "pr-8", // Make room for the action button - isActive && "bg-sidebar-accent text-sidebar-accent-foreground", - isDeletingChat && "opacity-50" - )} - > - - {chat.name} - - - {/* Actions dropdown - positioned absolutely */} - {chat.actions && chat.actions.length > 0 && ( -
- - - - - - {chat.actions.map((action, actionIndex) => { - const ActionIcon = actionIconMap[action.icon] || MessageCircleMore; - const isDeleteAction = action.name.toLowerCase().includes("delete"); - - return ( - { - if (isDeleteAction) { - handleDeleteChat(chat.id || 0, action.onClick); - } else { - action.onClick(); - } - }} - disabled={isDeletingChat} - className={ - isDeleteAction - ? "text-destructive focus:text-destructive" - : "" - } - > - - - {isDeletingChat && isDeleteAction - ? t("deleting") || "Deleting..." - : action.name} - - - ); - })} - - -
- )} -
- ); - })} -
-
- ) : ( -
- - {t("no_recent_chats") || "No recent chats"} -
- )} -
-
- - {/* All Chats Sheet */} - {searchSpaceId && ( - setOpenMobile(false)} - /> - )} -
- ); -} diff --git a/surfsense_web/components/sidebar/nav-main.tsx b/surfsense_web/components/sidebar/nav-main.tsx deleted file mode 100644 index a0dbe912f..000000000 --- a/surfsense_web/components/sidebar/nav-main.tsx +++ /dev/null @@ -1,207 +0,0 @@ -"use client"; - -import { ChevronRight, type LucideIcon } from "lucide-react"; -import { usePathname } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useMemo, useState } from "react"; - -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, -} from "@/components/ui/sidebar"; - -interface NavItem { - title: string; - url: string; - icon: LucideIcon; - isActive?: boolean; - items?: { - title: string; - url: string; - }[]; -} - -interface NavMainProps { - items: NavItem[]; -} - -export function NavMain({ items }: NavMainProps) { - const t = useTranslations("nav_menu"); - const pathname = usePathname(); - - // Translation function that handles both exact matches and fallback to original - const translateTitle = (title: string): string => { - const titleMap: Record = { - Researcher: "researcher", - "Manage LLMs": "manage_llms", - Sources: "sources", - "Manage Documents": "manage_documents", - "Manage Connectors": "manage_connectors", - Podcasts: "podcasts", - Logs: "logs", - Platform: "platform", - Team: "team", - }; - - const key = titleMap[title]; - return key ? t(key) : title; - }; - - // Check if an item is active based on pathname - const isItemActive = useCallback( - (item: NavItem): boolean => { - if (!pathname) return false; - - // For items without sub-items, check if pathname matches or starts with the URL - if (!item.items?.length) { - // Chat item: active ONLY when on new-chat page without a specific chat ID - // (i.e., exactly /dashboard/{id}/new-chat, not /dashboard/{id}/new-chat/123) - if (item.url.includes("/new-chat")) { - // Match exactly the new-chat base URL (ends with /new-chat) - return pathname.endsWith("/new-chat"); - } - // Logs item: active when on logs page - if (item.url.includes("/logs")) { - return pathname.includes("/logs"); - } - // Check exact match or prefix match - return pathname === item.url || pathname.startsWith(`${item.url}/`); - } - - // For items with sub-items (like Sources), check if any sub-item URL matches - return item.items.some( - (subItem) => pathname === subItem.url || pathname.startsWith(subItem.url) - ); - }, - [pathname] - ); - - // Memoize items to prevent unnecessary re-renders - const memoizedItems = useMemo(() => items, [items]); - - // Track expanded state for items with sub-menus (like Sources) - const [expandedItems, setExpandedItems] = useState>(() => { - const initial: Record = {}; - items.forEach((item) => { - if (item.items?.length) { - initial[item.title] = item.isActive ?? false; - } - }); - return initial; - }); - - // Handle collapsible state change - const handleOpenChange = useCallback((title: string, isOpen: boolean) => { - setExpandedItems((prev) => ({ ...prev, [title]: isOpen })); - }, []); - - return ( - - {translateTitle("Platform")} - - {memoizedItems.map((item, index) => { - const translatedTitle = translateTitle(item.title); - const hasSub = !!item.items?.length; - const isActive = isItemActive(item); - const isItemOpen = expandedItems[item.title] ?? isActive ?? false; - return ( - handleOpenChange(item.title, open) : undefined} - defaultOpen={!hasSub ? isActive : undefined} - > - - {hasSub ? ( - // When the item has children, make the whole row a collapsible trigger - <> - - - - - - - - - - Toggle submenu - - - - - - {item.items?.map((subItem, subIndex) => { - const translatedSubTitle = translateTitle(subItem.title); - const isDocumentsLink = - subItem.title === "Manage Documents" || - translatedSubTitle.toLowerCase().includes("documents"); - return ( - - - - {translatedSubTitle} - - - - ); - })} - - - - ) : ( - // Leaf item: treat as a normal link - - - - {translatedTitle} - - - )} - - - ); - })} - - - ); -} diff --git a/surfsense_web/components/sidebar/nav-notes.tsx b/surfsense_web/components/sidebar/nav-notes.tsx deleted file mode 100644 index e9f94fe80..000000000 --- a/surfsense_web/components/sidebar/nav-notes.tsx +++ /dev/null @@ -1,287 +0,0 @@ -"use client"; - -import { - ChevronRight, - FileText, - FolderOpen, - Loader2, - type LucideIcon, - MoreHorizontal, - Plus, - Trash2, -} from "lucide-react"; -import { usePathname, useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useMemo, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar"; -import { useLogsSummary } from "@/hooks/use-logs"; -import { cn } from "@/lib/utils"; -import { AllNotesSidebar } from "./all-notes-sidebar"; - -interface NoteAction { - name: string; - icon: string; - onClick: () => void; -} - -interface NoteItem { - name: string; - url: string; - icon: LucideIcon; - id?: number; - search_space_id?: number; - actions?: NoteAction[]; -} - -interface NavNotesProps { - notes: NoteItem[]; - onAddNote?: () => void; - defaultOpen?: boolean; - searchSpaceId?: string; -} - -// Map of icon names to their components -const actionIconMap: Record = { - FileText, - Trash2, - MoreHorizontal, -}; - -export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) { - const t = useTranslations("sidebar"); - const router = useRouter(); - const pathname = usePathname(); - const { setOpenMobile } = useSidebar(); - const [isDeleting, setIsDeleting] = useState(null); - const [isOpen, setIsOpen] = useState(defaultOpen); - const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false); - - // Poll for active reindexing tasks to show inline loading indicators - // Smart polling: only polls when there are active tasks, stops when idle - const { summary } = useLogsSummary(searchSpaceId ? Number(searchSpaceId) : 0, 24, { - enablePolling: true, - refetchInterval: 5000, // Poll every 5 seconds when tasks are active - }); - - // Create a Set of document IDs that are currently being reindexed - const reindexingDocumentIds = useMemo(() => { - if (!summary?.active_tasks) return new Set(); - return new Set( - summary.active_tasks - .filter((task) => task.document_id != null) - .map((task) => task.document_id as number) - ); - }, [summary?.active_tasks]); - - // Handle note deletion with loading state - const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => { - setIsDeleting(noteId); - try { - await deleteAction(); - } finally { - setIsDeleting(null); - } - }, []); - - // Handle note navigation - const handleNoteClick = useCallback( - (url: string) => { - router.push(url); - }, - [router] - ); - - return ( - - -
- - - - {t("notes") || "Notes"} - - - - {/* Action buttons - always visible on hover */} -
- {searchSpaceId && notes.length > 0 && ( - - )} - {onAddNote && ( - - )} -
-
- - - - - {notes.length > 0 ? ( - notes.map((note) => { - const isDeletingNote = isDeleting === note.id; - const isActive = pathname === note.url; - const isReindexing = note.id ? reindexingDocumentIds.has(note.id) : false; - - return ( - - {/* Main navigation button */} - handleNoteClick(note.url)} - disabled={isDeletingNote} - className={cn( - "pr-8", // Make room for the action button - isActive && "bg-sidebar-accent text-sidebar-accent-foreground", - isDeletingNote && "opacity-50" - )} - > - {isReindexing ? ( - - ) : ( - - )} - {note.name} - - - {/* Actions dropdown - positioned absolutely */} - {note.actions && note.actions.length > 0 && ( -
- - - - - - {note.actions.map((action, actionIndex) => { - const ActionIcon = actionIconMap[action.icon] || FileText; - const isDeleteAction = action.name.toLowerCase().includes("delete"); - - return ( - { - if (isDeleteAction) { - handleDeleteNote(note.id || 0, action.onClick); - } else { - action.onClick(); - } - }} - disabled={isDeletingNote} - className={ - isDeleteAction - ? "text-destructive focus:text-destructive" - : "" - } - > - - - {isDeletingNote && isDeleteAction - ? t("deleting") || "Deleting..." - : action.name} - - - ); - })} - - -
- )} -
- ); - }) - ) : ( - - {onAddNote ? ( - - - {t("create_new_note") || "Create a new note"} - - ) : ( - - - {t("no_notes") || "No notes yet"} - - )} - - )} -
-
-
-
- - {/* All Notes Sheet */} - {searchSpaceId && ( - setOpenMobile(false)} - /> - )} -
- ); -} diff --git a/surfsense_web/components/sidebar/nav-secondary.tsx b/surfsense_web/components/sidebar/nav-secondary.tsx deleted file mode 100644 index 23aeabc38..000000000 --- a/surfsense_web/components/sidebar/nav-secondary.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import type { LucideIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import type * as React from "react"; -import { useMemo } from "react"; - -import { - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, -} from "@/components/ui/sidebar"; - -interface NavSecondaryItem { - title: string; - url: string; - icon: LucideIcon; -} - -export function NavSecondary({ - items, - ...props -}: { - items: NavSecondaryItem[]; -} & React.ComponentPropsWithoutRef) { - const t = useTranslations("sidebar"); - - // Memoize items to prevent unnecessary re-renders - const memoizedItems = useMemo(() => items, [items]); - - return ( - - {t("search_space")} - - {memoizedItems.map((item, index) => ( - - {item.url === "#" ? ( - // Non-interactive display item (e.g., search space name) -
- - {item.title} -
- ) : ( - // Interactive link item - - - - {item.title} - - - )} -
- ))} -
-
- ); -} diff --git a/surfsense_web/components/sidebar/page-usage-display.tsx b/surfsense_web/components/sidebar/page-usage-display.tsx deleted file mode 100644 index 6c640c0aa..000000000 --- a/surfsense_web/components/sidebar/page-usage-display.tsx +++ /dev/null @@ -1,57 +0,0 @@ -"use client"; - -import { Mail } from "lucide-react"; -import { Progress } from "@/components/ui/progress"; -import { - SidebarGroup, - SidebarGroupContent, - SidebarGroupLabel, - useSidebar, -} from "@/components/ui/sidebar"; - -interface PageUsageDisplayProps { - pagesUsed: number; - pagesLimit: number; -} - -export function PageUsageDisplay({ pagesUsed, pagesLimit }: PageUsageDisplayProps) { - const { state } = useSidebar(); - const usagePercentage = (pagesUsed / pagesLimit) * 100; - const isCollapsed = state === "collapsed"; - - return ( - - - Page Usage - - -
- {isCollapsed ? ( - // Show only a compact progress indicator when collapsed -
- -
- ) : ( - // Show full details when expanded - <> -
- - {pagesUsed.toLocaleString()} / {pagesLimit.toLocaleString()} pages - - {usagePercentage.toFixed(0)}% -
- - - - Contact to increase limits - - - )} -
-
-
- ); -} diff --git a/surfsense_web/content/docs/connectors/meta.json b/surfsense_web/content/docs/connectors/meta.json index 7a075fbb5..9b416afdd 100644 --- a/surfsense_web/content/docs/connectors/meta.json +++ b/surfsense_web/content/docs/connectors/meta.json @@ -9,6 +9,7 @@ "discord", "jira", "linear", + "microsoft-teams", "confluence", "airtable", "clickup", diff --git a/surfsense_web/content/docs/connectors/microsoft-teams.mdx b/surfsense_web/content/docs/connectors/microsoft-teams.mdx new file mode 100644 index 000000000..daa6eb375 --- /dev/null +++ b/surfsense_web/content/docs/connectors/microsoft-teams.mdx @@ -0,0 +1,101 @@ +--- +title: Microsoft Teams +description: Connect your Microsoft Teams to SurfSense +--- + +# Microsoft Teams OAuth Integration Setup Guide + +This guide walks you through setting up a Microsoft Teams OAuth integration for SurfSense using Azure App Registration. + +## Step 1: Access Azure App Registrations + +1. Navigate to [portal.azure.com](https://portal.azure.com) +2. In the search bar, type **"app reg"** +3. Select **"App registrations"** from the Services results + +![Azure Portal Search](/docs/connectors/microsoft-teams/azure-search-app-reg.png) + +## Step 2: Create New Registration + +1. On the **App registrations** page, click **"+ New registration"** + +![App Registrations Page](/docs/connectors/microsoft-teams/azure-app-registrations.png) + +## Step 3: Register the Application + +Fill in the application details: + +| Field | Value | +|-------|-------| +| **Name** | `SurfSense` | +| **Supported account types** | Select **"Accounts in any organizational directory (Any Microsoft Entra ID tenant - Multitenant) and personal Microsoft accounts"** | +| **Redirect URI** | Platform: `Web`, URI: `http://localhost:8000/api/v1/auth/teams/connector/callback` | + +Click **"Register"** + +![Register Application Form](/docs/connectors/microsoft-teams/azure-register-app.png) + +## Step 4: Get Application (Client) ID + +After registration, you'll be taken to the app's **Overview** page. Here you'll find: + +1. Copy the **Application (client) ID** - this is your Client ID +2. Note the **Directory (tenant) ID** if needed + +![Application Overview](/docs/connectors/microsoft-teams/azure-app-overview.png) + +## Step 5: Create Client Secret + +1. In the left sidebar under **Manage**, click **"Certificates & secrets"** +2. Select the **"Client secrets"** tab +3. Click **"+ New client secret"** +4. Enter a description (e.g., `SurfSense`) and select an expiration period +5. Click **"Add"** + +![Certificates & Secrets - Empty](/docs/connectors/microsoft-teams/azure-certificates-empty.png) + +6. **Important**: Copy the secret **Value** immediately - it won't be shown again! + +![Certificates & Secrets - Created](/docs/connectors/microsoft-teams/azure-certificates-created.png) + +> ⚠️ Never share your client secret publicly or include it in code repositories. + +## Step 6: Configure API Permissions + +1. In the left sidebar under **Manage**, click **"API permissions"** +2. Click **"+ Add a permission"** +3. Select **"Microsoft Graph"** +4. Select **"Delegated permissions"** +5. Add the following permissions: + +| Permission | Type | Description | Admin Consent | +|------------|------|-------------|---------------| +| `Channel.ReadBasic.All` | Delegated | Read the names and descriptions of channels | No | +| `ChannelMessage.Read.All` | Delegated | Read user channel messages | Yes | +| `offline_access` | Delegated | Maintain access to data you have given it access to | No | +| `Team.ReadBasic.All` | Delegated | Read the names and descriptions of teams | No | +| `User.Read` | Delegated | Sign in and read user profile | No | + +6. Click **"Add permissions"** + +> ⚠️ The `ChannelMessage.Read.All` permission requires admin consent. An admin will need to click **"Grant admin consent for [Directory]"** for full functionality. + +![API Permissions](/docs/connectors/microsoft-teams/azure-api-permissions.png) + +--- + +## Running SurfSense with Microsoft Teams Connector + +Add the Microsoft Teams environment variables to your Docker run command: + +```bash +docker run -d -p 3000:3000 -p 8000:8000 \ + -v surfsense-data:/data \ + # Microsoft Teams Connector + -e TEAMS_CLIENT_ID=your_microsoft_client_id \ + -e TEAMS_CLIENT_SECRET=your_microsoft_client_secret \ + -e TEAMS_REDIRECT_URI=http://localhost:8000/api/v1/auth/teams/connector/callback \ + --name surfsense \ + --restart unless-stopped \ + ghcr.io/modsetter/surfsense:latest +``` diff --git a/surfsense_web/messages/en.json b/surfsense_web/messages/en.json index 6c64e62ba..b803d4b69 100644 --- a/surfsense_web/messages/en.json +++ b/surfsense_web/messages/en.json @@ -622,7 +622,15 @@ "chat_archived": "Chat archived", "chat_unarchived": "Chat restored", "no_archived_chats": "No archived chats", - "error_archiving_chat": "Failed to archive chat" + "error_archiving_chat": "Failed to archive chat", + "new_chat": "New chat", + "select_workspace": "Select Workspace", + "invite_members": "Invite members", + "workspace_settings": "Workspace settings", + "see_all_workspaces": "See all search spaces", + "expand_sidebar": "Expand sidebar", + "collapse_sidebar": "Collapse sidebar", + "logout": "Logout" }, "errors": { "something_went_wrong": "Something went wrong", diff --git a/surfsense_web/messages/zh.json b/surfsense_web/messages/zh.json index 67069cf55..fa690bf39 100644 --- a/surfsense_web/messages/zh.json +++ b/surfsense_web/messages/zh.json @@ -616,7 +616,15 @@ "more_options": "更多选项", "clear_search": "清除搜索", "view_all_notes": "查看所有笔记", - "add_note": "添加笔记" + "add_note": "添加笔记", + "new_chat": "新对话", + "select_workspace": "选择工作空间", + "invite_members": "邀请成员", + "workspace_settings": "工作空间设置", + "see_all_workspaces": "查看所有搜索空间", + "expand_sidebar": "展开侧边栏", + "collapse_sidebar": "收起侧边栏", + "logout": "退出登录" }, "errors": { "something_went_wrong": "出错了", diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-api-permissions.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-api-permissions.png new file mode 100644 index 000000000..f362a3344 Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-api-permissions.png differ diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-app-overview.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-app-overview.png new file mode 100644 index 000000000..27a4290e7 Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-app-overview.png differ diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-app-registrations.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-app-registrations.png new file mode 100644 index 000000000..f7865fe5e Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-app-registrations.png differ diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-certificates-created.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-certificates-created.png new file mode 100644 index 000000000..abfc90dde Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-certificates-created.png differ diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-certificates-empty.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-certificates-empty.png new file mode 100644 index 000000000..603d79155 Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-certificates-empty.png differ diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-register-app.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-register-app.png new file mode 100644 index 000000000..d1a5d1b6e Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-register-app.png differ diff --git a/surfsense_web/public/docs/connectors/microsoft-teams/azure-search-app-reg.png b/surfsense_web/public/docs/connectors/microsoft-teams/azure-search-app-reg.png new file mode 100644 index 000000000..974b9d013 Binary files /dev/null and b/surfsense_web/public/docs/connectors/microsoft-teams/azure-search-app-reg.png differ