diff --git a/surfsense_backend/alembic/versions/103_add_last_login_to_user.py b/surfsense_backend/alembic/versions/103_add_last_login_to_user.py new file mode 100644 index 000000000..20a061082 --- /dev/null +++ b/surfsense_backend/alembic/versions/103_add_last_login_to_user.py @@ -0,0 +1,39 @@ +"""103_add_last_login_to_user + +Revision ID: 103 +Revises: 102 +Create Date: 2026-03-08 + +Adds last_login timestamp column to the user table so we can track +when each user last authenticated. The column is nullable — existing +rows will have NULL until the user's next login. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "103" +down_revision: str | None = "102" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + conn = op.get_bind() + existing_columns = [col["name"] for col in sa.inspect(conn).get_columns("user")] + + if "last_login" not in existing_columns: + op.add_column( + "user", + sa.Column("last_login", sa.TIMESTAMP(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("user", "last_login") diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 510f64cc3..9f0af4fc5 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1720,6 +1720,8 @@ if config.AUTH_TYPE == "GOOGLE": display_name = Column(String, nullable=True) avatar_url = Column(String, nullable=True) + last_login = Column(TIMESTAMP(timezone=True), nullable=True) + # Refresh tokens for this user refresh_tokens = relationship( "RefreshToken", @@ -1820,6 +1822,8 @@ else: display_name = Column(String, nullable=True) avatar_url = Column(String, nullable=True) + last_login = Column(TIMESTAMP(timezone=True), nullable=True) + # Refresh tokens for this user refresh_tokens = relationship( "RefreshToken", diff --git a/surfsense_backend/app/prompts/__init__.py b/surfsense_backend/app/prompts/__init__.py index efa31d612..98909a906 100644 --- a/surfsense_backend/app/prompts/__init__.py +++ b/surfsense_backend/app/prompts/__init__.py @@ -109,12 +109,12 @@ SUMMARY_PROMPT_TEMPLATE = PromptTemplate( # Chat Title Generation Prompt # ============================================================================= -TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following conversation. +TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the following user query. - The title MUST be between 1 and 6 words - The title MUST be on a single line -- Capture the main topic or intent of the conversation +- Capture the main topic or intent of the query - Do NOT use quotes, punctuation, or formatting - Do NOT include words like "Chat about" or "Discussion of" - Return ONLY the title, nothing else @@ -124,13 +124,9 @@ TITLE_GENERATION_PROMPT = """Generate a concise, descriptive title for the follo {user_query} - -{assistant_response} - - Title:""" TITLE_GENERATION_PROMPT_TEMPLATE = PromptTemplate( - input_variables=["user_query", "assistant_response"], + input_variables=["user_query"], template=TITLE_GENERATION_PROMPT, ) diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py index 865fdf7b3..bb5df0c13 100644 --- a/surfsense_backend/app/routes/documents_routes.py +++ b/surfsense_backend/app/routes/documents_routes.py @@ -320,6 +320,8 @@ async def read_documents( page_size: int = 50, search_space_id: int | None = None, document_types: str | None = None, + sort_by: str = "created_at", + sort_order: str = "desc", session: AsyncSession = Depends(get_async_session), user: User = Depends(current_active_user), ): @@ -392,6 +394,19 @@ async def read_documents( total_result = await session.execute(count_query) total = total_result.scalar() or 0 + # Apply sorting + from sqlalchemy import asc as sa_asc, desc as sa_desc + + sort_column_map = { + "created_at": Document.created_at, + "title": Document.title, + "document_type": Document.document_type, + } + sort_col = sort_column_map.get(sort_by, Document.created_at) + query = query.order_by( + sa_desc(sort_col) if sort_order == "desc" else sa_asc(sort_col) + ) + # Calculate offset offset = 0 if skip is not None: diff --git a/surfsense_backend/app/routes/notifications_routes.py b/surfsense_backend/app/routes/notifications_routes.py index 4f80c6529..4fa2026ed 100644 --- a/surfsense_backend/app/routes/notifications_routes.py +++ b/surfsense_backend/app/routes/notifications_routes.py @@ -10,7 +10,7 @@ from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel -from sqlalchemy import desc, func, select, update +from sqlalchemy import desc, func, literal, literal_column, select, update from sqlalchemy.ext.asyncio import AsyncSession from app.db import Notification, User, get_async_session @@ -23,9 +23,26 @@ SYNC_WINDOW_DAYS = 14 # Valid notification types - must match frontend InboxItemTypeEnum NotificationType = Literal[ - "connector_indexing", "document_processing", "new_mention", "page_limit_exceeded" + "connector_indexing", + "connector_deletion", + "document_processing", + "new_mention", + "comment_reply", + "page_limit_exceeded", ] +# Category-to-types mapping for filtering by tab +NotificationCategory = Literal["comments", "status"] +CATEGORY_TYPES: dict[str, tuple[str, ...]] = { + "comments": ("new_mention", "comment_reply"), + "status": ( + "connector_indexing", + "connector_deletion", + "document_processing", + "page_limit_exceeded", + ), +} + class NotificationResponse(BaseModel): """Response model for a single notification.""" @@ -69,6 +86,21 @@ class MarkAllReadResponse(BaseModel): updated_count: int +class SourceTypeItem(BaseModel): + """A single source type with its category and count.""" + + key: str + type: str + category: str # "connector" or "document" + count: int + + +class SourceTypesResponse(BaseModel): + """Response for notification source types used in status tab filter.""" + + sources: list[SourceTypeItem] + + class UnreadCountResponse(BaseModel): """Response for unread count with split between recent and older items.""" @@ -76,12 +108,86 @@ class UnreadCountResponse(BaseModel): recent_unread: int # Within SYNC_WINDOW_DAYS +@router.get("/source-types", response_model=SourceTypesResponse) +async def get_notification_source_types( + search_space_id: int | None = Query(None, description="Filter by search space ID"), + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> SourceTypesResponse: + """ + Get all distinct connector types and document types from the user's + status notifications. Used to populate the filter dropdown in the + inbox Status tab so that all types are shown regardless of pagination. + """ + base_filter = [Notification.user_id == user.id] + + if search_space_id is not None: + base_filter.append( + (Notification.search_space_id == search_space_id) + | (Notification.search_space_id.is_(None)) + ) + + connector_type_expr = Notification.notification_metadata["connector_type"].astext + connector_query = ( + select( + connector_type_expr.label("source_type"), + literal("connector").label("category"), + func.count(Notification.id).label("cnt"), + ) + .where( + *base_filter, + Notification.type.in_(("connector_indexing", "connector_deletion")), + connector_type_expr.isnot(None), + ) + .group_by(literal_column("source_type")) + ) + + document_type_expr = Notification.notification_metadata["document_type"].astext + document_query = ( + select( + document_type_expr.label("source_type"), + literal("document").label("category"), + func.count(Notification.id).label("cnt"), + ) + .where( + *base_filter, + Notification.type.in_(("document_processing",)), + document_type_expr.isnot(None), + ) + .group_by(literal_column("source_type")) + ) + + connector_result = await session.execute(connector_query) + document_result = await session.execute(document_query) + + sources = [] + for source_type, category, count in [ + *connector_result.all(), + *document_result.all(), + ]: + if not source_type: + continue + sources.append( + SourceTypeItem( + key=f"{category}:{source_type}", + type=source_type, + category=category, + count=count, + ) + ) + + return SourceTypesResponse(sources=sources) + + @router.get("/unread-count", response_model=UnreadCountResponse) async def get_unread_count( search_space_id: int | None = Query(None, description="Filter by search space ID"), type_filter: NotificationType | None = Query( None, alias="type", description="Filter by notification type" ), + category: NotificationCategory | None = Query( + None, description="Filter by category: 'comments' or 'status'" + ), user: User = Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ) -> UnreadCountResponse: @@ -116,6 +222,10 @@ async def get_unread_count( if type_filter: base_filter.append(Notification.type == type_filter) + # Filter by category (maps to multiple types) + if category: + base_filter.append(Notification.type.in_(CATEGORY_TYPES[category])) + # Total unread count (all time) total_query = select(func.count(Notification.id)).where(*base_filter) total_result = await session.execute(total_query) @@ -141,6 +251,17 @@ async def list_notifications( type_filter: NotificationType | None = Query( None, alias="type", description="Filter by notification type" ), + category: NotificationCategory | None = Query( + None, description="Filter by category: 'comments' or 'status'" + ), + source_type: str | None = Query( + None, + description="Filter by source type, e.g. 'connector:GITHUB_CONNECTOR' or 'doctype:FILE'", + ), + filter: str | None = Query( + None, + description="Filter preset: 'unread' for unread only, 'errors' for failed/error items only", + ), before_date: str | None = Query( None, description="Get notifications before this ISO date (for pagination)" ), @@ -182,6 +303,45 @@ async def list_notifications( query = query.where(Notification.type == type_filter) count_query = count_query.where(Notification.type == type_filter) + # Filter by category (maps to multiple types) + if category: + cat_types = CATEGORY_TYPES[category] + query = query.where(Notification.type.in_(cat_types)) + count_query = count_query.where(Notification.type.in_(cat_types)) + + # Filter by source type (connector or document type from JSONB metadata) + if source_type: + if source_type.startswith("connector:"): + connector_val = source_type[len("connector:") :] + source_filter = Notification.type.in_( + ("connector_indexing", "connector_deletion") + ) & ( + Notification.notification_metadata["connector_type"].astext + == connector_val + ) + query = query.where(source_filter) + count_query = count_query.where(source_filter) + elif source_type.startswith("doctype:"): + doctype_val = source_type[len("doctype:") :] + source_filter = Notification.type.in_(("document_processing",)) & ( + Notification.notification_metadata["document_type"].astext + == doctype_val + ) + query = query.where(source_filter) + count_query = count_query.where(source_filter) + + # Filter by preset: 'unread' or 'errors' + if filter == "unread": + unread_filter = Notification.read == False # noqa: E712 + query = query.where(unread_filter) + count_query = count_query.where(unread_filter) + elif filter == "errors": + error_filter = (Notification.type == "page_limit_exceeded") | ( + Notification.notification_metadata["status"].astext == "failed" + ) + query = query.where(error_filter) + count_query = count_query.where(error_filter) + # Filter by date (for efficient pagination of older items) if before_date: try: diff --git a/surfsense_backend/app/routes/rbac_routes.py b/surfsense_backend/app/routes/rbac_routes.py index 7d2cc5c77..38ae31269 100644 --- a/surfsense_backend/app/routes/rbac_routes.py +++ b/surfsense_backend/app/routes/rbac_routes.py @@ -510,6 +510,7 @@ async def list_members( "user_email": member_user.email if member_user else None, "user_display_name": member_user.display_name if member_user else None, "user_avatar_url": member_user.avatar_url if member_user else None, + "user_last_login": member_user.last_login if member_user else None, } response.append(membership_dict) @@ -602,6 +603,7 @@ async def update_member_role( "created_at": db_membership.created_at, "role": db_membership.role, "user_email": member_user.email if member_user else None, + "user_last_login": member_user.last_login if member_user else None, } except HTTPException: diff --git a/surfsense_backend/app/schemas/rbac_schemas.py b/surfsense_backend/app/schemas/rbac_schemas.py index 031eef3d2..8de8426c3 100644 --- a/surfsense_backend/app/schemas/rbac_schemas.py +++ b/surfsense_backend/app/schemas/rbac_schemas.py @@ -77,6 +77,7 @@ class MembershipRead(BaseModel): user_email: str | None = None user_display_name: str | None = None user_avatar_url: str | None = None + user_last_login: datetime | None = None class Config: from_attributes = True diff --git a/surfsense_backend/app/tasks/chat/stream_new_chat.py b/surfsense_backend/app/tasks/chat/stream_new_chat.py index 8d09ff387..3f0cee145 100644 --- a/surfsense_backend/app/tasks/chat/stream_new_chat.py +++ b/surfsense_backend/app/tasks/chat/stream_new_chat.py @@ -1366,6 +1366,38 @@ async def stream_new_chat( del mentioned_documents, mentioned_surfsense_docs, recent_reports del langchain_messages, final_query + # Check if this is the first assistant response so we can generate + # a title in parallel with the agent stream (better UX than waiting + # until after the full response). + assistant_count_result = await session.execute( + select(func.count(NewChatMessage.id)).filter( + NewChatMessage.thread_id == chat_id, + NewChatMessage.role == "assistant", + ) + ) + is_first_response = (assistant_count_result.scalar() or 0) == 0 + + title_task: asyncio.Task[str | None] | None = None + if is_first_response: + + async def _generate_title() -> str | None: + try: + title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm + title_result = await title_chain.ainvoke( + {"user_query": user_query[:500]} + ) + if title_result and hasattr(title_result, "content"): + raw_title = title_result.content.strip() + if raw_title and len(raw_title) <= 100: + return raw_title.strip("\"'") + except Exception: + pass + return None + + title_task = asyncio.create_task(_generate_title()) + + title_emitted = False + _t_stream_start = time.perf_counter() _first_event_logged = False async for sse in _stream_agent_events( @@ -1390,6 +1422,23 @@ async def stream_new_chat( _first_event_logged = True yield sse + # Inject title update mid-stream as soon as the background task finishes + if title_task is not None and title_task.done() and not title_emitted: + generated_title = title_task.result() + if generated_title: + async with shielded_async_session() as title_session: + title_thread_result = await title_session.execute( + select(NewChatThread).filter(NewChatThread.id == chat_id) + ) + title_thread = title_thread_result.scalars().first() + if title_thread: + title_thread.title = generated_title + await title_session.commit() + yield streaming_service.format_thread_title_update( + chat_id, generated_title + ) + title_emitted = True + _perf_log.info( "[stream_new_chat] Agent stream completed in %.3fs (chat_id=%s)", time.perf_counter() - _t_stream_start, @@ -1398,62 +1447,28 @@ async def stream_new_chat( log_system_snapshot("stream_new_chat_END") if stream_result.is_interrupted: + if title_task is not None and not title_task.done(): + title_task.cancel() yield streaming_service.format_finish_step() yield streaming_service.format_finish() yield streaming_service.format_done() return - accumulated_text = stream_result.accumulated_text - - assistant_count_result = await session.execute( - select(func.count(NewChatMessage.id)).filter( - NewChatMessage.thread_id == chat_id, - NewChatMessage.role == "assistant", - ) - ) - assistant_message_count = assistant_count_result.scalar() or 0 - - # Only generate title on the first response (no prior assistant messages) - if assistant_message_count == 0: - generated_title = None - try: - # Generate title using the same LLM - title_chain = TITLE_GENERATION_PROMPT_TEMPLATE | llm - # Truncate inputs to avoid context length issues - truncated_query = user_query[:500] - truncated_response = accumulated_text[:1000] - title_result = await title_chain.ainvoke( - { - "user_query": truncated_query, - "assistant_response": truncated_response, - } - ) - - # Extract and clean the title - if title_result and hasattr(title_result, "content"): - raw_title = title_result.content.strip() - # Validate the title (reasonable length) - if raw_title and len(raw_title) <= 100: - # Remove any quotes or extra formatting - generated_title = raw_title.strip("\"'") - except Exception: - generated_title = None - - # Only update if LLM succeeded (keep truncated prompt title as fallback) + # If the title task didn't finish during streaming, await it now + if title_task is not None and not title_emitted: + generated_title = await title_task if generated_title: - # Fetch thread and update title - thread_result = await session.execute( - select(NewChatThread).filter(NewChatThread.id == chat_id) - ) - thread = thread_result.scalars().first() - if thread: - thread.title = generated_title - await session.commit() - - # Notify frontend of the title update - yield streaming_service.format_thread_title_update( - chat_id, generated_title + async with shielded_async_session() as title_session: + title_thread_result = await title_session.execute( + select(NewChatThread).filter(NewChatThread.id == chat_id) ) + title_thread = title_thread_result.scalars().first() + if title_thread: + title_thread.title = generated_title + await title_session.commit() + yield streaming_service.format_thread_title_update( + chat_id, generated_title + ) # Finish the step and message yield streaming_service.format_finish_step() diff --git a/surfsense_backend/app/users.py b/surfsense_backend/app/users.py index 7ec657781..d24a6faf1 100644 --- a/surfsense_backend/app/users.py +++ b/surfsense_backend/app/users.py @@ -1,5 +1,6 @@ import logging import uuid +from datetime import UTC, datetime import httpx from fastapi import Depends, Request, Response @@ -12,6 +13,7 @@ from fastapi_users.authentication import ( ) from fastapi_users.db import SQLAlchemyUserDatabase from pydantic import BaseModel +from sqlalchemy import update from app.config import config from app.db import ( @@ -123,6 +125,23 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): return user + async def on_after_login( + self, + user: User, + request: Request | None = None, + response: Response | None = None, + ) -> None: + try: + async with async_session_maker() as session: + await session.execute( + update(User) + .where(User.id == user.id) + .values(last_login=datetime.now(UTC)) + ) + await session.commit() + except Exception as e: + logger.warning(f"Failed to update last_login for user {user.id}: {e}") + async def on_after_register(self, user: User, request: Request | None = None): """ Called after a user registers. Creates a default search space for the user diff --git a/surfsense_web/app/(home)/page.tsx b/surfsense_web/app/(home)/page.tsx index e0478fce3..2c1a70ac9 100644 --- a/surfsense_web/app/(home)/page.tsx +++ b/surfsense_web/app/(home)/page.tsx @@ -1,10 +1,29 @@ "use client"; -import { CTAHomepage } from "@/components/homepage/cta"; -import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid"; -import { FeaturesCards } from "@/components/homepage/features-card"; +import dynamic from "next/dynamic"; import { HeroSection } from "@/components/homepage/hero-section"; -import ExternalIntegrations from "@/components/homepage/integrations"; + +const FeaturesCards = dynamic( + () => import("@/components/homepage/features-card").then((m) => ({ default: m.FeaturesCards })), + { ssr: false } +); + +const FeaturesBentoGrid = dynamic( + () => + import("@/components/homepage/features-bento-grid").then((m) => ({ + default: m.FeaturesBentoGrid, + })), + { ssr: false } +); + +const ExternalIntegrations = dynamic(() => import("@/components/homepage/integrations"), { + ssr: false, +}); + +const CTAHomepage = dynamic( + () => import("@/components/homepage/cta").then((m) => ({ default: m.CTAHomepage })), + { ssr: false } +); export default function HomePage() { return ( 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 83a579970..25e4e990b 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -13,9 +13,7 @@ import { llmPreferencesAtom, } from "@/atoms/new-llm-config/new-llm-config-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; -import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup"; import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup"; -import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { LayoutDataProvider } from "@/components/layout"; import { OnboardingTour } from "@/components/onboarding-tour"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; @@ -27,8 +25,6 @@ export function DashboardClientLayout({ }: { children: React.ReactNode; searchSpaceId: string; - navSecondary?: any[]; - navMain?: any[]; }) { const t = useTranslations("dashboard"); const router = useRouter(); @@ -190,11 +186,7 @@ export function DashboardClientLayout({ return ( - }> - {children} - - {/* Global connector dialog - triggered from documents page */} - + {children} ); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx index ff9e1f246..cceae36d9 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx @@ -1,32 +1,9 @@ "use client"; -import { useSetAtom } from "jotai"; -import { - CircleAlert, - FileType, - ListFilter, - Search, - SlidersHorizontal, - Trash, - Upload, - X, -} from "lucide-react"; -import { motion } from "motion/react"; +import { ListFilter, Search, Upload, X } from "lucide-react"; import { useTranslations } from "next-intl"; -import React, { useMemo, useRef, useState } from "react"; -import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; +import React, { useCallback, useMemo, useRef, useState } from "react"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; @@ -36,18 +13,14 @@ import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon"; export function DocumentsFilters({ typeCounts: typeCountsRecord, - selectedIds, onSearch, searchValue, - onBulkDelete, onToggleType, activeTypes, }: { typeCounts: Partial>; - selectedIds: Set; onSearch: (v: string) => void; searchValue: string; - onBulkDelete: () => Promise; onToggleType: (type: DocumentTypeEnum, checked: boolean) => void; activeTypes: DocumentTypeEnum[]; }) { @@ -55,11 +28,16 @@ export function DocumentsFilters({ const id = React.useId(); const inputRef = useRef(null); - // Dialog hooks for action buttons const { openDialog: openUploadDialog } = useDocumentUploadDialog(); - const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom); const [typeSearchQuery, setTypeSearchQuery] = useState(""); + const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top"); + const handleScroll = useCallback((e: React.UIEvent) => { + const el = e.currentTarget; + const atTop = el.scrollTop <= 2; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; + setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + }, []); const uniqueTypes = useMemo(() => { return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[]; @@ -80,235 +58,153 @@ export function DocumentsFilters({ }, [typeCountsRecord]); return ( - - {/* Main toolbar row */} -
- {/* Action Buttons - Left Side */} -
- - -
+
+
+ {/* Type Filter */} + + + + + +
+ {/* Search input */} +
+
+ + setTypeSearchQuery(e.target.value)} + className="h-6 pl-6 text-sm bg-transparent border-0 shadow-none focus-visible:ring-0" + /> +
+
- {/* Spacer */} -
+
+ {filteredTypes.length === 0 ? ( +
+ No types found +
+ ) : ( + filteredTypes.map((value: DocumentTypeEnum, i) => ( +
onToggleType(value, !activeTypes.includes(value))} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggleType(value, !activeTypes.includes(value)); + } + }} + > + {/* Icon */} +
+ {getDocumentTypeIcon(value, "h-4 w-4")} +
+ {/* Text content */} +
+ + {getDocumentTypeLabel(value)} + + + {typeCounts.get(value)} document + {(typeCounts.get(value) ?? 0) !== 1 ? "s" : ""} + +
+ {/* Checkbox */} + onToggleType(value, !!checked)} + className="h-4 w-4 shrink-0 rounded border-muted-foreground/30 data-[state=checked]:bg-primary data-[state=checked]:border-primary" + /> +
+ )) + )} +
+ {activeTypes.length > 0 && ( +
+ +
+ )} +
+ + {/* Search Input */} - +
-
onSearch(e.target.value)} - placeholder="Filter by title" + placeholder="Search docs" type="text" aria-label={t("filter_placeholder")} /> {Boolean(searchValue) && ( - { onSearch(""); inputRef.current?.focus(); }} - initial={{ opacity: 0, scale: 0.8 }} - animate={{ opacity: 1, scale: 1 }} - exit={{ opacity: 0, scale: 0.8 }} - whileHover={{ scale: 1.1 }} - whileTap={{ scale: 0.9 }} > - )} - - - {/* Filter Buttons Group */} -
- {/* Type Filter */} - - - - - -
- {/* Search input */} -
-
- - setTypeSearchQuery(e.target.value)} - className="h-6 pl-6 text-sm bg-transparent border-0 focus-visible:ring-0" - /> -
-
- -
- {filteredTypes.length === 0 ? ( -
- No types found -
- ) : ( - filteredTypes.map((value: DocumentTypeEnum, i) => ( -
onToggleType(value, !activeTypes.includes(value))} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - onToggleType(value, !activeTypes.includes(value)); - } - }} - > - {/* Icon */} -
- {getDocumentTypeIcon(value, "h-4 w-4")} -
- {/* Text content */} -
- - {getDocumentTypeLabel(value)} - - - {typeCounts.get(value)} document - {(typeCounts.get(value) ?? 0) !== 1 ? "s" : ""} - -
- {/* Checkbox */} - onToggleType(value, !!checked)} - className="h-4 w-4 shrink-0 rounded border-muted-foreground/30 data-[state=checked]:bg-primary data-[state=checked]:border-primary" - /> -
- )) - )} -
- {activeTypes.length > 0 && ( -
- -
- )} -
-
-
- - {/* Bulk Delete Button */} - {selectedIds.size > 0 && ( - - - - {/* Mobile: icon with count */} - - {/* Desktop: full button */} - - - - -
- - - - Delete {selectedIds.size} document{selectedIds.size !== 1 ? "s" : ""}? - - - This action cannot be undone. This will permanently delete the selected{" "} - {selectedIds.size === 1 ? "document" : "documents"} from your search space. - - -
- - Cancel - - Delete - - -
-
+ )}
+ + {/* Upload Button */} +
-
+
); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index 93e1cadbf..cddb3e79a 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -1,29 +1,53 @@ "use client"; -import { formatDistanceToNow } from "date-fns"; import { AlertCircle, - BadgeInfo, - Calendar, CheckCircle2, ChevronDown, ChevronUp, Clock, + Eye, FileText, FileX, Network, - Plus, - User, + PenLine, + SearchX, + Trash2, } from "lucide-react"; -import { motion } from "motion/react"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import React, { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; import { MarkdownViewer } from "@/components/markdown-viewer"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { + Drawer, + DrawerContent, + DrawerHandle, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; import { @@ -35,12 +59,14 @@ import { TableRow, } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useLongPress } from "@/hooks/use-long-press"; import { documentsApiService } from "@/lib/apis/documents-api.service"; -import { DocumentTypeChip } from "./DocumentTypeIcon"; -import { RowActions } from "./RowActions"; -import type { ColumnVisibility, Document, DocumentStatus } from "./types"; +import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon"; +import type { Document, DocumentStatus } from "./types"; + +const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const; +const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const; -// Status indicator component for document processing status function StatusIndicator({ status }: { status?: DocumentStatus }) { const state = status?.state ?? "ready"; @@ -107,10 +133,6 @@ function sortDocuments(docs: Document[], key: SortKey, desc: boolean): Document[ return desc ? sorted.reverse() : sorted; } -function formatRelativeDate(dateStr: string): string { - return formatDistanceToNow(new Date(dateStr), { addSuffix: true }); -} - function formatAbsoluteDate(dateStr: string): string { const date = new Date(dateStr); return date.toLocaleString("en-US", { @@ -123,7 +145,7 @@ function formatAbsoluteDate(dateStr: string): string { }); } -function TruncatedText({ text, className }: { text: string; className?: string }) { +function DocumentNameTooltip({ doc, className }: { doc: Document; className?: string }) { const textRef = useRef(null); const [isTruncated, setIsTruncated] = useState(false); @@ -138,25 +160,27 @@ function TruncatedText({ text, className }: { text: string; className?: string } return () => window.removeEventListener("resize", checkTruncation); }, []); - if (isTruncated) { - return ( - - - - {text} - - - -

{text}

-
-
- ); - } - return ( - - {text} - + + + + {doc.title} + + + +
+ {isTruncated &&

{doc.title}

} +

+ Owner:{" "} + {doc.created_by_name || doc.created_by_email || "—"} +

+

+ Created:{" "} + {formatAbsoluteDate(doc.created_at)} +

+
+
+
); } @@ -180,9 +204,9 @@ function SortableHeader({ +
+ )}
- + ) : ( +
+ + + {sorted.map((doc) => { + const isMentioned = mentionedDocIds?.has(doc.id) ?? false; + const canInteract = isSelectable(doc); + const handleRowToggle = () => { + if (canInteract && onToggleChatMention) { + onToggleChatMention(doc, isMentioned); + } + }; + const handleRowClick = (e: React.MouseEvent) => { + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + handleViewMetadata(doc); + return; + } + handleRowToggle(); + }; + return ( + + + e.stopPropagation()} + > +
+ handleRowToggle()} + disabled={!canInteract} + aria-label={isMentioned ? "Remove from chat" : "Add to chat"} + className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`} + /> +
+
+ + + + + + + + {getDocumentTypeIcon(doc.document_type, "h-4 w-4")} + + + + {getDocumentTypeLabel(doc.document_type)} + + + + + + + + + ); + })} + +
+ {hasMore &&
} +
+ )} +
+ + {/* Mobile Card View */} + {loading ? ( +
+ {[70, 85, 55, 78, 62, 90].map((widthPercent) => ( +
+
+ +
+ +
+
+ + +
+
+
+ ))} +
) : error ? ( -
+

{t("error_loading")}

) : sorted.length === 0 ? ( -
- -
- +
+ {isSearchMode ? ( +
+ +
+

No matching documents

+

+ Try a different search term or adjust your filters. +

+
-
-

{t("no_documents")}

-

- Get started by uploading your first document. -

+ ) : ( +
+
+ +
+
+

{t("no_documents")}

+

+ Get started by uploading your first document. +

+
+
- - + )}
) : ( - <> - {/* Desktop Table View */} -
- {/* Fixed Header */} - - - - -
- toggleAll(!!v)} - aria-label="Select all" - className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary" - /> -
-
- - } - > - Document - - - {columnVisibility.document_type && ( - - } - > - Source - - - )} - {columnVisibility.created_by && ( - - - - User - - - )} - {columnVisibility.created_at && ( - - } - > - Created - - - )} - {columnVisibility.status && ( - - - - Status - - - )} - - Actions - -
-
-
- {/* Scrollable Body */} -
- - - {sorted.map((doc, index) => { - const title = doc.title; - const isSelected = selectedIds.has(doc.id); - const canSelect = isSelectable(doc); - return ( - - -
- canSelect && toggleOne(doc.id, !!v)} - disabled={!canSelect} - aria-label={ - canSelect ? "Select row" : "Cannot select while processing" - } - className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canSelect ? "opacity-40 cursor-not-allowed" : ""}`} - /> -
-
- - - - {columnVisibility.document_type && ( - - - - )} - {columnVisibility.created_by && ( - - {doc.created_by_name ? ( - doc.created_by_email ? ( - - - - {doc.created_by_name} - - - - {doc.created_by_email} - - - ) : ( - {doc.created_by_name} - ) - ) : ( - {doc.created_by_email || "—"} - )} - - )} - {columnVisibility.created_at && ( - - - - - {formatRelativeDate(doc.created_at)} - - - - {formatAbsoluteDate(doc.created_at)} - - - - )} - {columnVisibility.status && ( - - - - )} - - - -
- ); - })} -
-
-
-
- - {/* Mobile Card View - Notion Style */} -
- {sorted.map((doc, index) => { - const isSelected = selectedIds.has(doc.id); - const canSelect = isSelectable(doc); - return ( - + {sorted.map((doc) => { + const isMentioned = mentionedDocIds?.has(doc.id) ?? false; + const canInteract = isSelectable(doc); + const handleCardClick = (e?: React.MouseEvent) => { + if (e && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + e.stopPropagation(); + handleViewMetadata(doc); + return; + } + if (canInteract && onToggleChatMention) { + onToggleChatMention(doc, isMentioned); + } + }; + return ( + setMobileActionDoc(doc)}> +
-
- canSelect && toggleOne(doc.id, !!v)} - disabled={!canSelect} - aria-label={canSelect ? "Select row" : "Cannot select while processing"} - className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canSelect ? "opacity-40 cursor-not-allowed" : ""}`} + {canInteract && hasChatMode && ( + -
- - {columnVisibility.created_by && doc.created_by_name && ( - {doc.created_by_name} - )} - {columnVisibility.created_at && ( - - - - {formatRelativeDate(doc.created_at)} - - - - {formatAbsoluteDate(doc.created_at)} - - - )} -
-
-
- {columnVisibility.status && } - + + handleCardClick()} + disabled={!canInteract} + aria-label={isMentioned ? "Remove from chat" : "Add to chat"} + className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary shrink-0 ${!canInteract ? "opacity-40 cursor-not-allowed" : ""}`} /> + +
+ {doc.title} +
+
+ + {getDocumentTypeIcon(doc.document_type, "h-4 w-4")} + +
- - ); - })} -
- +
+ + ); + })} + {hasMore &&
} +
)} - {/* Metadata Viewer - opened via Ctrl/Cmd+Click on document title */} - {/* Lazy loads metadata from API for real-time synced documents */} - { - if (!open) handleCloseMetadata(); - }} - /> - - {/* Document Content Viewer - lazy loads content on-demand */} + {/* Document Content Viewer */} !open && handleCloseViewer()}> - + - {viewingDoc?.title} + + {viewingDoc?.title} + -
+
{viewingLoading ? (
@@ -797,6 +836,127 @@ export function DocumentsTableShell({
- + + {/* Document Metadata Viewer (Ctrl+Click) */} + { + if (!open) { + setMetadataDoc(null); + setMetadataJson(null); + setMetadataLoading(false); + } + }} + /> + + {/* Delete Confirmation Dialog */} + !open && setDeleteDoc(null)}> + + + Delete document? + + This action cannot be undone. This will permanently delete this document from your + search space. + + + + Cancel + { + e.preventDefault(); + handleDeleteFromMenu(); + }} + disabled={isDeleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting ? "Deleting" : "Delete"} + + + + + + {/* Mobile Document Actions Drawer */} + !open && setMobileActionDoc(null)}> + + + + {mobileActionDoc?.title} +
+

+ Owner:{" "} + {mobileActionDoc?.created_by_name || mobileActionDoc?.created_by_email || "—"} +

+

+ Created:{" "} + {mobileActionDoc ? formatAbsoluteDate(mobileActionDoc.created_at) : ""} +

+
+
+
+ + {mobileActionDoc && + EDITABLE_DOCUMENT_TYPES.includes( + mobileActionDoc.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number] + ) && ( + + )} + {mobileActionDoc && + !NON_DELETABLE_DOCUMENT_TYPES.includes( + mobileActionDoc.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number] + ) && ( + + )} +
+
+
+
); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx index ba252bd16..d44fae373 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx @@ -95,7 +95,6 @@ export function RowActions({ {/* Desktop Actions */}
{isEditable ? ( - // Editable documents: show 3-dot dropdown with edit + delete - {/* Mobile close button */} - -
- {/* Settings Title */} -
-

{t("title")}

-
-
- - {/* Navigation Items */} - - - - ); -} - -function SettingsContent({ - activeSection, - searchSpaceId, - onMenuClick, -}: { - activeSection: string; - searchSpaceId: number; - onMenuClick: () => void; -}) { - const t = useTranslations("searchSpaceSettings"); - const activeItem = settingsNavItems.find((item) => item.id === activeSection); - const Icon = activeItem?.icon || Settings; - - return ( - -
-
- {/* Section Header */} - - -
- {/* Mobile menu button */} - - - - -
-

- {activeItem ? t(activeItem.labelKey) : ""} -

-
-
-
-
- - {/* Section Content */} - - - {activeSection === "general" && ( - - )} - {activeSection === "models" && } - {activeSection === "roles" && } - {activeSection === "image-models" && ( - - )} - {activeSection === "prompts" && } - {activeSection === "public-links" && ( - - )} - {activeSection === "team-roles" && } - - -
-
-
- ); -} - -const VALID_SECTIONS = new Set(settingsNavItems.map((item) => item.id)); -const DEFAULT_SECTION = "general"; +const DEFAULT_TAB = "general"; export default function SettingsPage() { + const t = useTranslations("searchSpaceSettings"); const router = useRouter(); const params = useParams(); const searchParams = useSearchParams(); const searchSpaceId = Number(params.search_space_id); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - const sectionParam = searchParams.get("section"); - const activeSection = - sectionParam && VALID_SECTIONS.has(sectionParam) ? sectionParam : DEFAULT_SECTION; + const tabParam = searchParams.get("tab") ?? ""; + const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number]) + ? tabParam + : DEFAULT_TAB; - const handleSectionChange = useCallback( - (section: string) => { - router.replace(`/dashboard/${searchSpaceId}/settings?section=${section}`, { scroll: false }); + const handleTabChange = useCallback( + (value: string) => { + const p = new URLSearchParams(searchParams.toString()); + p.set("tab", value); + router.replace(`?${p.toString()}`, { scroll: false }); }, - [router, searchSpaceId] + [router, searchParams] ); useEffect(() => { - trackSettingsViewed(searchSpaceId, activeSection); - }, [searchSpaceId, activeSection]); - - const handleBackToApp = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/new-chat`); - }, [router, searchSpaceId]); + trackSettingsViewed(searchSpaceId, activeTab); + }, [searchSpaceId, activeTab]); return ( - -
-
- setIsSidebarOpen(false)} - /> - setIsSidebarOpen(true)} - /> -
+
+
+ + + + + {t("nav_general")} + + + + {t("nav_agent_configs")} + + + + {t("nav_role_assignments")} + + + + {t("nav_image_models")} + + + + {t("nav_team_roles")} + + + + {t("nav_system_instructions")} + + + + {t("nav_public_links")} + + + + + + + + + + + + + + + + + + + + + + + +
- +
); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx index c33c2e341..831a48ed2 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/page.tsx @@ -138,6 +138,7 @@ function getAvatarInitials(member: Membership): string { } const PAGE_SIZE = 5; +const SKELETON_KEYS = Array.from({ length: PAGE_SIZE }, (_, i) => `skeleton-${i}`); export default function TeamManagementPage() { const params = useParams(); @@ -290,11 +291,8 @@ export default function TeamManagementPage() { - {Array.from({ length: PAGE_SIZE }).map((_, i) => ( - + {SKELETON_KEYS.map((id) => ( +
@@ -546,7 +544,7 @@ function MemberRow({ - {formatRelativeDate(member.joined_at)} + {member.user_last_login ? formatRelativeDate(member.user_last_login) : "Never"} @@ -564,7 +562,7 @@ function MemberRow({ e.preventDefault()} - className="min-w-[120px] bg-muted dark:border dark:border-neutral-700" + className="min-w-[120px] dark:bg-neutral-900 dark:border dark:border-white/5" > {canManageRoles && roles @@ -581,8 +579,8 @@ function MemberRow({ e.preventDefault()} + className="text-destructive focus:text-destructive" > Remove @@ -607,11 +605,9 @@ function MemberRow({ )} - + - router.push(`/dashboard/${searchSpaceId}/settings?section=team-roles`) - } + onClick={() => router.push(`/dashboard/${searchSpaceId}/settings?tab=team-roles`)} > Manage Roles @@ -811,7 +807,7 @@ function CreateInviteDialog({
- - diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx new file mode 100644 index 000000000..67a7c32a4 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ApiKeyContent.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { Check, Copy, Info } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useTranslations } from "next-intl"; +import { useCallback, useRef, useState } from "react"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { useApiKey } from "@/hooks/use-api-key"; +import { copyToClipboard as copyToClipboardUtil } from "@/lib/utils"; + +export function ApiKeyContent() { + const t = useTranslations("userSettings"); + const { apiKey, isLoading, copied, copyToClipboard } = useApiKey(); + const [copiedUsage, setCopiedUsage] = useState(false); + const usageCopyTimeoutRef = useRef>(null); + + const copyUsageToClipboard = useCallback(async () => { + const text = `Authorization: Bearer ${apiKey || "YOUR_API_KEY"}`; + const success = await copyToClipboardUtil(text); + if (success) { + setCopiedUsage(true); + if (usageCopyTimeoutRef.current) clearTimeout(usageCopyTimeoutRef.current); + usageCopyTimeoutRef.current = setTimeout(() => setCopiedUsage(false), 2000); + } + }, [apiKey]); + + return ( + + + + + {t("api_key_warning_title")} + + {t("api_key_warning_description")} + + + +
+

{t("your_api_key")}

+ {isLoading ? ( +
+ ) : apiKey ? ( +
+
+

+ {apiKey} +

+
+ + + + + + {copied ? t("copied") : t("copy")} + + +
+ ) : ( +

{t("no_api_key")}

+ )} +
+ +
+

{t("usage_title")}

+

{t("usage_description")}

+
+
+
+								Authorization: Bearer {apiKey || "YOUR_API_KEY"}
+							
+
+ + + + + + {copiedUsage ? t("copied") : t("copy")} + + +
+
+ + + ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx new file mode 100644 index 000000000..49f23e85a --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/ProfileContent.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useAtomValue } from "jotai"; +import { AnimatePresence, motion } from "motion/react"; +import Image from "next/image"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms"; +import { currentUserAtom } from "@/atoms/user/user-query.atoms"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; + +function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) { + const [errorUrl, setErrorUrl] = useState(); + const hasError = errorUrl === url; + + if (url && !hasError) { + return ( + Avatar setErrorUrl(url)} + unoptimized + /> + ); + } + + return ( +
+ {fallback} +
+ ); +} + +export function ProfileContent() { + const t = useTranslations("userSettings"); + const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom); + const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom); + + const [displayName, setDisplayName] = useState(""); + + useEffect(() => { + if (user) { + setDisplayName(user.display_name || ""); + } + }, [user]); + + const getInitials = (email: string) => { + const name = email.split("@")[0]; + return name.slice(0, 2).toUpperCase(); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + try { + await updateUser({ + display_name: displayName || null, + }); + toast.success(t("profile_saved")); + } catch { + toast.error(t("profile_save_error")); + } + }; + + const hasChanges = displayName !== (user?.display_name || ""); + + return ( + + + {isUserLoading ? ( +
+ +
+ ) : ( +
+
+
+
+ + +
+ +
+ + setDisplayName(e.target.value)} + /> +

{t("profile_display_name_hint")}

+
+ +
+ + +
+
+
+ +
+ +
+
+ )} +
+
+ ); +} diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx new file mode 100644 index 000000000..8cdfdc178 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/page.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { User, UserKey } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/animated-tabs"; +import { ApiKeyContent } from "./components/ApiKeyContent"; +import { ProfileContent } from "./components/ProfileContent"; + +const VALID_TABS = ["profile", "api-key"] as const; +const DEFAULT_TAB = "profile"; + +export default function UserSettingsPage() { + const t = useTranslations("userSettings"); + const router = useRouter(); + const searchParams = useSearchParams(); + + const tabParam = searchParams.get("tab") ?? ""; + const activeTab = VALID_TABS.includes(tabParam as (typeof VALID_TABS)[number]) + ? tabParam + : DEFAULT_TAB; + + const handleTabChange = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + params.set("tab", value); + router.replace(`?${params.toString()}`, { scroll: false }); + }, + [router, searchParams] + ); + + return ( +
+
+ + + + + {t("profile_nav_label")} + + + + {t("api_key_nav_label")} + + + + + + + + + +
+
+ ); +} diff --git a/surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx b/surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx deleted file mode 100644 index 6bf10a78f..000000000 --- a/surfsense_web/app/dashboard/user/settings/components/ApiKeyContent.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"use client"; - -import { Check, Copy, Key, Menu, Shield } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useTranslations } from "next-intl"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { useApiKey } from "@/hooks/use-api-key"; - -interface ApiKeyContentProps { - onMenuClick: () => void; -} - -export function ApiKeyContent({ onMenuClick }: ApiKeyContentProps) { - const t = useTranslations("userSettings"); - const { apiKey, isLoading, copied, copyToClipboard } = useApiKey(); - - return ( - -
-
- - -
- - - - -
-

- {t("api_key_title")} -

-

{t("api_key_description")}

-
-
-
-
- - - - - - {t("api_key_warning_title")} - {t("api_key_warning_description")} - - -
-

{t("your_api_key")}

- {isLoading ? ( -
- ) : apiKey ? ( -
-
- {apiKey} -
- - - - - - {copied ? t("copied") : t("copy")} - - -
- ) : ( -

{t("no_api_key")}

- )} -
- -
-

{t("usage_title")}

-

{t("usage_description")}

-
-									Authorization: Bearer {apiKey || "YOUR_API_KEY"}
-								
-
- - -
-
- - ); -} diff --git a/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx b/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx deleted file mode 100644 index a1ff4d781..000000000 --- a/surfsense_web/app/dashboard/user/settings/components/ProfileContent.tsx +++ /dev/null @@ -1,182 +0,0 @@ -"use client"; - -import { useAtomValue } from "jotai"; -import { Menu, User } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useTranslations } from "next-intl"; -import { useEffect, useState } from "react"; -import { toast } from "sonner"; -import { updateUserMutationAtom } from "@/atoms/user/user-mutation.atoms"; -import { currentUserAtom } from "@/atoms/user/user-query.atoms"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Spinner } from "@/components/ui/spinner"; - -interface ProfileContentProps { - onMenuClick: () => void; -} - -function AvatarDisplay({ url, fallback }: { url?: string; fallback: string }) { - const [hasError, setHasError] = useState(false); - - useEffect(() => { - setHasError(false); - }, [url]); - - if (url && !hasError) { - return ( - Avatar setHasError(true)} - /> - ); - } - - return ( -
- {fallback} -
- ); -} - -export function ProfileContent({ onMenuClick }: ProfileContentProps) { - const t = useTranslations("userSettings"); - const { data: user, isLoading: isUserLoading } = useAtomValue(currentUserAtom); - const { mutateAsync: updateUser, isPending } = useAtomValue(updateUserMutationAtom); - - const [displayName, setDisplayName] = useState(""); - - useEffect(() => { - if (user) { - setDisplayName(user.display_name || ""); - } - }, [user]); - - const getInitials = (email: string) => { - const name = email.split("@")[0]; - return name.slice(0, 2).toUpperCase(); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - try { - await updateUser({ - display_name: displayName || null, - }); - toast.success(t("profile_saved")); - } catch { - toast.error(t("profile_save_error")); - } - }; - - const hasChanges = displayName !== (user?.display_name || ""); - - return ( - -
-
- - -
- - - - -
-

- {t("profile_title")} -

-

{t("profile_description")}

-
-
-
-
- - - - {isUserLoading ? ( -
- -
- ) : ( -
-
-
-
- - -
- -
- - setDisplayName(e.target.value)} - /> -

- {t("profile_display_name_hint")} -

-
- -
- - -
-
-
- -
- -
-
- )} -
-
-
-
-
- ); -} diff --git a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx b/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx deleted file mode 100644 index 3424113a9..000000000 --- a/surfsense_web/app/dashboard/user/settings/components/UserSettingsSidebar.tsx +++ /dev/null @@ -1,160 +0,0 @@ -"use client"; - -import type { LucideIcon } from "lucide-react"; -import { ArrowLeft, ChevronRight, X } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; -import { useTranslations } from "next-intl"; -import { Button } from "@/components/ui/button"; -import { APP_VERSION } from "@/lib/env-config"; -import { cn } from "@/lib/utils"; - -export interface SettingsNavItem { - id: string; - label: string; - description: string; - icon: LucideIcon; -} - -interface UserSettingsSidebarProps { - activeSection: string; - onSectionChange: (section: string) => void; - onBackToApp: () => void; - isOpen: boolean; - onClose: () => void; - navItems: SettingsNavItem[]; -} - -export function UserSettingsSidebar({ - activeSection, - onSectionChange, - onBackToApp, - isOpen, - onClose, - navItems, -}: UserSettingsSidebarProps) { - const t = useTranslations("userSettings"); - - const handleNavClick = (sectionId: string) => { - onSectionChange(sectionId); - onClose(); - }; - - return ( - <> - - {isOpen && ( - - )} - - - - - ); -} diff --git a/surfsense_web/app/dashboard/user/settings/page.tsx b/surfsense_web/app/dashboard/user/settings/page.tsx deleted file mode 100644 index 8e04ce37a..000000000 --- a/surfsense_web/app/dashboard/user/settings/page.tsx +++ /dev/null @@ -1,64 +0,0 @@ -"use client"; - -import { Key, User } from "lucide-react"; -import { motion } from "motion/react"; -import { useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useState } from "react"; -import { ApiKeyContent } from "./components/ApiKeyContent"; -import { ProfileContent } from "./components/ProfileContent"; -import { type SettingsNavItem, UserSettingsSidebar } from "./components/UserSettingsSidebar"; - -export default function UserSettingsPage() { - const t = useTranslations("userSettings"); - const router = useRouter(); - const [activeSection, setActiveSection] = useState("profile"); - const [isSidebarOpen, setIsSidebarOpen] = useState(false); - - const navItems: SettingsNavItem[] = [ - { - id: "profile", - label: t("profile_nav_label"), - description: t("profile_nav_description"), - icon: User, - }, - { - id: "api-key", - label: t("api_key_nav_label"), - description: t("api_key_nav_description"), - icon: Key, - }, - ]; - - const handleBackToApp = useCallback(() => { - router.back(); - }, [router]); - - return ( - -
-
- setIsSidebarOpen(false)} - navItems={navItems} - /> - {activeSection === "profile" && ( - setIsSidebarOpen(true)} /> - )} - {activeSection === "api-key" && ( - setIsSidebarOpen(true)} /> - )} -
-
-
- ); -} diff --git a/surfsense_web/app/globals.css b/surfsense_web/app/globals.css index c192a27be..1f44ead92 100644 --- a/surfsense_web/app/globals.css +++ b/surfsense_web/app/globals.css @@ -195,6 +195,18 @@ button { background-color: hsl(var(--muted-foreground) / 0.4); } +/* Hide scrollbar on mobile, only visible while scrolling */ +@media (max-width: 767px) { + .scrollbar-thin { + scrollbar-width: none; + } + + .scrollbar-thin::-webkit-scrollbar { + width: 0; + display: none; + } +} + /* Human-in-the-loop approval card animations */ @keyframes pulse-subtle { 0%, @@ -231,7 +243,7 @@ button { } } -@source '../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}'; -@source '../node_modules/streamdown/dist/*.js'; -@source '../node_modules/@streamdown/code/dist/*.js'; -@source '../node_modules/@streamdown/math/dist/*.js'; +@source "../node_modules/@llamaindex/chat-ui/**/*.{ts,tsx}"; +@source "../node_modules/streamdown/dist/*.js"; +@source "../node_modules/@streamdown/code/dist/*.js"; +@source "../node_modules/@streamdown/math/dist/*.js"; diff --git a/surfsense_web/app/layout.tsx b/surfsense_web/app/layout.tsx index 5e8fa394f..6166cd714 100644 --- a/surfsense_web/app/layout.tsx +++ b/surfsense_web/app/layout.tsx @@ -108,6 +108,9 @@ export default function RootLayout({ // Locale state is managed by LocaleContext and persisted in localStorage return ( + + + diff --git a/surfsense_web/app/verify-token/route.ts b/surfsense_web/app/verify-token/route.ts index 1c11d6ce0..b7ed762de 100644 --- a/surfsense_web/app/verify-token/route.ts +++ b/surfsense_web/app/verify-token/route.ts @@ -1,4 +1,4 @@ -import { NextRequest, NextResponse } from "next/server"; +import { type NextRequest, NextResponse } from "next/server"; const backendBaseUrl = (process.env.INTERNAL_FASTAPI_BACKEND_URL || "http://backend:8000").replace( /\/+$/, diff --git a/surfsense_web/atoms/chat/mentioned-documents.atom.ts b/surfsense_web/atoms/chat/mentioned-documents.atom.ts index aba2736e5..ee93a409a 100644 --- a/surfsense_web/atoms/chat/mentioned-documents.atom.ts +++ b/surfsense_web/atoms/chat/mentioned-documents.atom.ts @@ -1,26 +1,47 @@ "use client"; import { atom } from "jotai"; -import type { Document, SurfsenseDocsDocument } from "@/contracts/types/document.types"; +import type { Document } from "@/contracts/types/document.types"; /** - * Atom to store the IDs of documents mentioned in the current chat composer. - * This is used to pass document context to the backend when sending a message. - */ -export const mentionedDocumentIdsAtom = atom<{ - surfsense_doc_ids: number[]; - document_ids: number[]; -}>({ - surfsense_doc_ids: [], - document_ids: [], -}); - -/** - * Atom to store the full document objects mentioned in the current chat composer. - * This persists across component remounts. + * Atom to store the full document objects mentioned via @-mention chips + * in the current chat composer. This persists across component remounts. */ export const mentionedDocumentsAtom = atom[]>([]); +/** + * Atom to store documents selected via the sidebar checkboxes / row clicks. + * These are NOT inserted as chips – the composer shows a count badge instead. + */ +export const sidebarSelectedDocumentsAtom = atom< + Pick[] +>([]); + +/** + * Derived read-only atom that merges @-mention chips and sidebar selections + * into a single deduplicated set of document IDs for the backend. + */ +export const mentionedDocumentIdsAtom = atom((get) => { + const chipDocs = get(mentionedDocumentsAtom); + const sidebarDocs = get(sidebarSelectedDocumentsAtom); + const allDocs = [...chipDocs, ...sidebarDocs]; + const seen = new Set(); + const deduped = allDocs.filter((d) => { + const key = `${d.document_type}:${d.id}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + return { + surfsense_doc_ids: deduped + .filter((doc) => doc.document_type === "SURFSENSE_DOCS") + .map((doc) => doc.id), + document_ids: deduped + .filter((doc) => doc.document_type !== "SURFSENSE_DOCS") + .map((doc) => doc.id), + }; +}); + /** * Simplified document info for display purposes */ diff --git a/surfsense_web/atoms/documents/document-mutation.atoms.ts b/surfsense_web/atoms/documents/document-mutation.atoms.ts index 8089bacd4..736db896c 100644 --- a/surfsense_web/atoms/documents/document-mutation.atoms.ts +++ b/surfsense_web/atoms/documents/document-mutation.atoms.ts @@ -49,7 +49,6 @@ export const uploadDocumentMutationAtom = atomWithMutation((get) => { onSuccess: () => { // Note: Toast notification is handled by the caller (DocumentUploadTab) to use i18n - // Invalidate logs summary to show new processing tasks immediately on documents page queryClient.invalidateQueries({ queryKey: cacheKeys.logs.summary(searchSpaceId ?? undefined), }); diff --git a/surfsense_web/atoms/documents/ui.atoms.ts b/surfsense_web/atoms/documents/ui.atoms.ts index 33740e9c7..a3d481c80 100644 --- a/surfsense_web/atoms/documents/ui.atoms.ts +++ b/surfsense_web/atoms/documents/ui.atoms.ts @@ -5,3 +5,5 @@ export const globalDocumentsQueryParamsAtom = atom { queryKey: cacheKeys.members.all(searchSpaceId?.toString() ?? ""), enabled: !!searchSpaceId, staleTime: 3 * 1000, // 3 seconds - short staleness for live collaboration + refetchInterval: 2 * 60 * 1000, // 2 minutes queryFn: async () => { if (!searchSpaceId) { return []; diff --git a/surfsense_web/biome.json b/surfsense_web/biome.json index 6e17e7f14..738a3636d 100644 --- a/surfsense_web/biome.json +++ b/surfsense_web/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.1.2/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", "vcs": { "enabled": true, "clientKind": "git", @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": true, - "experimentalScannerIgnores": ["node_modules", ".git", ".next", "dist", "build", "coverage"], + "includes": ["**", "!!node_modules", "!!.git", "!!.next", "!!dist", "!!build", "!!coverage"], "maxSize": 1048576 }, "formatter": { @@ -65,6 +65,9 @@ } }, "css": { + "parser": { + "tailwindDirectives": true + }, "formatter": { "enabled": true, "indentStyle": "tab", diff --git a/surfsense_web/components/announcements/AnnouncementCard.tsx b/surfsense_web/components/announcements/AnnouncementCard.tsx index daaecee07..792887e60 100644 --- a/surfsense_web/components/announcements/AnnouncementCard.tsx +++ b/surfsense_web/components/announcements/AnnouncementCard.tsx @@ -1,14 +1,6 @@ "use client"; -import { - Bell, - ExternalLink, - Info, - type LucideIcon, - Rocket, - Wrench, - Zap, -} from "lucide-react"; +import { Bell, ExternalLink, Info, type LucideIcon, Rocket, Wrench, Zap } from "lucide-react"; import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -114,4 +106,3 @@ export function AnnouncementCard({ announcement }: { announcement: AnnouncementW ); } - diff --git a/surfsense_web/components/announcements/AnnouncementsEmptyState.tsx b/surfsense_web/components/announcements/AnnouncementsEmptyState.tsx index 2ae926b1f..b4551f56a 100644 --- a/surfsense_web/components/announcements/AnnouncementsEmptyState.tsx +++ b/surfsense_web/components/announcements/AnnouncementsEmptyState.tsx @@ -15,4 +15,3 @@ export function AnnouncementsEmptyState() {
); } - diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 332694676..f234f46eb 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -4,7 +4,7 @@ import { useAtomValue } from "jotai"; import { AlertTriangle, Cable, Settings } from "lucide-react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import type { FC } from "react"; +import { type FC, useMemo } from "react"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { globalNewLLMConfigsAtom, @@ -37,7 +37,7 @@ import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab"; import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view"; import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view"; -export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger = false }) => { +export const ConnectorIndicator: FC = () => { const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchParams = useSearchParams(); const { data: currentUser } = useAtomValue(currentUserAtom); @@ -66,11 +66,15 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger const { data: documentTypeCounts, isFetching: documentTypesLoading } = useAtomValue(documentTypeCountsAtom); - // Fetch notifications to detect indexing failures - const { inboxItems = [] } = useInbox( + // Fetch status notifications to detect indexing failures + const { inboxItems: statusInboxItems = [] } = useInbox( currentUser?.id ?? null, searchSpaceId ? Number(searchSpaceId) : null, - "connector_indexing" + "status" + ); + const inboxItems = useMemo( + () => statusInboxItems.filter((item) => item.type === "connector_indexing"), + [statusInboxItems] ); // Check if YouTube view is active @@ -189,40 +193,36 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger return ( - {!hideTrigger && ( - handleOpenChange(true)} - > - {isLoading ? ( - - ) : ( - <> - - {activeConnectorsCount > 0 && ( - - {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount} - - )} - - )} - - )} + handleOpenChange(true)} + > + {isLoading ? ( + + ) : ( + <> + + {activeConnectorsCount > 0 && ( + + {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount} + + )} + + )} + - + Manage Connectors {/* YouTube Crawler View - shown when adding YouTube videos */} {isYouTubeView && searchSpaceId ? ( @@ -379,7 +379,7 @@ export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger : "You need to configure a Document Summary LLM before adding connectors. This LLM is used to process and summarize documents from your connected sources."}

{standaloneDocuments.map((doc) => ( diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/youtube-crawler-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/youtube-crawler-view.tsx index 0798ecfdb..7e246f847 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/views/youtube-crawler-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/views/youtube-crawler-view.tsx @@ -3,7 +3,6 @@ import { TagInput, type Tag as TagType } from "emblor"; import { useAtom } from "jotai"; import { ArrowLeft } from "lucide-react"; -import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { type FC, useState } from "react"; import { toast } from "sonner"; @@ -24,7 +23,6 @@ interface YouTubeCrawlerViewProps { export const YouTubeCrawlerView: FC = ({ searchSpaceId, onBack }) => { const t = useTranslations("add_youtube"); - const router = useRouter(); const [videoTags, setVideoTags] = useState([]); const [activeTagIndex, setActiveTagIndex] = useState(null); const [error, setError] = useState(null); @@ -74,9 +72,7 @@ export const YouTubeCrawlerView: FC = ({ searchSpaceId, toast(t("success_toast"), { description: t("success_toast_desc"), }); - // Close the popup and navigate to documents onBack(); - router.push(`/dashboard/${searchSpaceId}/documents`); }, onError: (error: unknown) => { const errorMessage = error instanceof Error ? error.message : t("error_generic"); diff --git a/surfsense_web/components/assistant-ui/document-upload-popup.tsx b/surfsense_web/components/assistant-ui/document-upload-popup.tsx index 5e2a0e437..c5a2eb977 100644 --- a/surfsense_web/components/assistant-ui/document-upload-popup.tsx +++ b/surfsense_web/components/assistant-ui/document-upload-popup.tsx @@ -120,7 +120,12 @@ const DocumentUploadPopupContent: FC<{ return ( - + e.preventDefault()} + onInteractOutside={(e) => e.preventDefault()} + onEscapeKeyDown={(e) => e.preventDefault()} + className="select-none max-w-4xl w-[95vw] sm:w-full h-[calc(100dvh-2rem)] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 bg-muted dark:bg-muted text-foreground [&>button]:right-3 sm:[&>button]:right-12 [&>button]:top-3 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4 sm:[&>button_svg]:size-5" + > Upload Document {/* Scrollable container for mobile */} @@ -153,7 +158,7 @@ const DocumentUploadPopupContent: FC<{ : "You need to configure a Document Summary LLM before uploading files. This LLM is used to process and summarize your uploaded documents."}

- - {/* Document picker popover (portal to body for proper z-index stacking) */} {showDocumentPopover && typeof document !== "undefined" && @@ -722,15 +460,7 @@ const Composer: FC = () => { />, document.body )} - uploadedMentionDocs[doc.id]?.state === "failed" - )} - /> +
); @@ -738,29 +468,20 @@ const Composer: FC = () => { interface ComposerActionProps { isBlockedByOtherUser?: boolean; - onUploadClick: () => void; - isUploadingDocs: boolean; - blockingUploadedMentionsCount: number; - hasFailedUploadedMentions: boolean; } -const ComposerAction: FC = ({ - isBlockedByOtherUser = false, - onUploadClick, - isUploadingDocs, - blockingUploadedMentionsCount, - hasFailedUploadedMentions, -}) => { +const ComposerAction: FC = ({ isBlockedByOtherUser = false }) => { const mentionedDocuments = useAtomValue(mentionedDocumentsAtom); + const sidebarDocs = useAtomValue(sidebarSelectedDocumentsAtom); + const setDocumentsSidebarOpen = useSetAtom(documentsSidebarOpenAtom); + const { openDialog: openUploadDialog } = useDocumentUploadDialog(); - // Check if composer text is empty (chips are represented in mentionedDocuments atom) const isComposerTextEmpty = useAssistantState(({ composer }) => { const text = composer.text?.trim() || ""; return text.length === 0; }); const isComposerEmpty = isComposerTextEmpty && mentionedDocuments.length === 0; - // Check if a model is configured const { data: userConfigs } = useAtomValue(newLLMConfigsAtom); const { data: globalConfigs } = useAtomValue(globalNewLLMConfigsAtom); const { data: preferences } = useAtomValue(llmPreferencesAtom); @@ -770,121 +491,91 @@ const ComposerAction: FC = ({ const agentLlmId = preferences.agent_llm_id; if (agentLlmId === null || agentLlmId === undefined) return false; - // Check if the configured model actually exists - // Auto mode (ID 0) and global configs (negative IDs) are in globalConfigs if (agentLlmId <= 0) { return globalConfigs?.some((c) => c.id === agentLlmId) ?? false; } return userConfigs?.some((c) => c.id === agentLlmId) ?? false; }, [preferences, globalConfigs, userConfigs]); - const isSendDisabled = - isComposerEmpty || - !hasModelConfigured || - isBlockedByOtherUser || - isUploadingDocs || - blockingUploadedMentionsCount > 0; + const isSendDisabled = isComposerEmpty || !hasModelConfigured || isBlockedByOtherUser; return (
- Upload and mention files - - Max 10 files 50 MB each - - Total upload limit: 200 MB -
- ) - } + tooltip="Upload" side="bottom" variant="ghost" size="icon" className="size-[34px] rounded-full p-1 font-semibold text-xs hover:bg-muted-foreground/15 dark:border-muted-foreground/15 dark:hover:bg-muted-foreground/30" - aria-label="Upload files" - onClick={onUploadClick} - disabled={isUploadingDocs} + aria-label="Upload documents" + onClick={openUploadDialog} > - {isUploadingDocs ? ( - - ) : ( - - )} +
- {blockingUploadedMentionsCount > 0 && ( -
- {hasFailedUploadedMentions ? : } - - {hasFailedUploadedMentions - ? "Remove or retry failed uploads" - : "Waiting for uploaded files to finish indexing"} - -
- )} - - {/* Show warning when no model is configured */} - {!hasModelConfigured && blockingUploadedMentionsCount === 0 && ( + {!hasModelConfigured && (
Select a model
)} - !thread.isRunning}> - - 0 - ? "Waiting for uploaded files to finish indexing" - : isUploadingDocs - ? "Uploading documents..." - : !hasModelConfigured - ? "Please select a model from the header to start chatting" - : isComposerEmpty - ? "Enter a message to send" - : "Send message" - } - side="bottom" - type="submit" - variant="default" - size="icon" - className={cn( - "aui-composer-send size-8 rounded-full", - isSendDisabled && "cursor-not-allowed opacity-50" - )} - aria-label="Send message" - disabled={isSendDisabled} - > - - - - - - thread.isRunning}> - - - - + {sidebarDocs.length} {sidebarDocs.length === 1 ? "source" : "sources"} selected + + )} + + !thread.isRunning}> + + + + + + + + thread.isRunning}> + + + + +
); }; diff --git a/surfsense_web/components/assistant-ui/user-message.tsx b/surfsense_web/components/assistant-ui/user-message.tsx index 7ba5b9462..1c0525277 100644 --- a/surfsense_web/components/assistant-ui/user-message.tsx +++ b/surfsense_web/components/assistant-ui/user-message.tsx @@ -1,6 +1,6 @@ import { ActionBarPrimitive, MessagePrimitive, useAssistantState } from "@assistant-ui/react"; import { useAtomValue } from "jotai"; -import { FileText, PencilIcon } from "lucide-react"; +import { FileText, Pen } from "lucide-react"; import { type FC, useState } from "react"; import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom"; import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; @@ -125,7 +125,7 @@ const UserActionBar: FC = () => { {canEdit && ( - + )} diff --git a/surfsense_web/components/chat-comments/comment-item/comment-actions.tsx b/surfsense_web/components/chat-comments/comment-item/comment-actions.tsx index 3e30fb185..b9768348a 100644 --- a/surfsense_web/components/chat-comments/comment-item/comment-actions.tsx +++ b/surfsense_web/components/chat-comments/comment-item/comment-actions.tsx @@ -36,7 +36,7 @@ export function CommentActions({ canEdit, canDelete, onEdit, onDelete }: Comment )} {canEdit && canDelete && } {canDelete && ( - + Delete diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx deleted file mode 100644 index d907ca6d1..000000000 --- a/surfsense_web/components/dashboard-breadcrumb.tsx +++ /dev/null @@ -1,216 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { usePathname } from "next/navigation"; -import { useTranslations } from "next-intl"; -import React, { useEffect, useState } from "react"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb"; -import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { authenticatedFetch, getBearerToken } from "@/lib/auth-utils"; -import { getThreadFull } from "@/lib/chat/thread-persistence"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; - -interface BreadcrumbItemInterface { - label: string; - href?: string; -} - -export function DashboardBreadcrumb() { - const t = useTranslations("breadcrumb"); - const pathname = usePathname(); - // Extract search space ID and chat ID from pathname - const segments = pathname.split("/").filter(Boolean); - const searchSpaceId = segments[0] === "dashboard" && segments[1] ? segments[1] : null; - - const { data: searchSpace } = useQuery({ - queryKey: cacheKeys.searchSpaces.detail(searchSpaceId || ""), - queryFn: () => searchSpacesApiService.getSearchSpace({ id: Number(searchSpaceId) }), - enabled: !!searchSpaceId, - }); - - // Extract chat thread ID from pathname for chat pages - const chatThreadId = segments[2] === "new-chat" && segments[3] ? segments[3] : null; - - // Fetch thread details when on a chat page with a thread ID - const { data: threadData } = useQuery({ - queryKey: ["threads", searchSpaceId, "detail", chatThreadId], - queryFn: () => getThreadFull(Number(chatThreadId)), - enabled: !!chatThreadId && !!searchSpaceId, - }); - - // State to store document title for editor breadcrumb - const [documentTitle, setDocumentTitle] = useState(null); - - // Fetch document title when on editor page - useEffect(() => { - if (segments[2] === "editor" && segments[3] && searchSpaceId) { - const documentId = segments[3]; - - // Skip fetch for "new" notes - if (documentId === "new") { - setDocumentTitle(null); - return; - } - - const token = getBearerToken(); - - if (token) { - authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`, - { method: "GET" } - ) - .then((res) => res.json()) - .then((data) => { - if (data.title) { - setDocumentTitle(data.title); - } - }) - .catch(() => { - // If fetch fails, just use the document ID - setDocumentTitle(null); - }); - } - } else { - setDocumentTitle(null); - } - }, [segments, searchSpaceId]); - - // Parse the pathname to create breadcrumb items - const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => { - const segments = path.split("/").filter(Boolean); - const breadcrumbs: BreadcrumbItemInterface[] = []; - - // Handle search space (start directly with search space, skip "Dashboard") - if (segments[0] === "dashboard" && segments[1]) { - // Use the actual search space name if available, otherwise fall back to the ID - const searchSpaceLabel = searchSpace?.name || `${t("search_space")} ${segments[1]}`; - breadcrumbs.push({ - label: searchSpaceLabel, - href: `/dashboard/${segments[1]}`, - }); - - // Handle specific sections - if (segments[2]) { - const section = segments[2]; - let sectionLabel = section.charAt(0).toUpperCase() + section.slice(1); - - // Map section names to more readable labels - const sectionLabels: Record = { - "new-chat": t("chat") || "Chat", - documents: t("documents"), - logs: t("logs"), - settings: t("settings"), - editor: t("editor"), - }; - - sectionLabel = sectionLabels[section] || sectionLabel; - - // Handle sub-sections - if (segments[3]) { - const subSection = segments[3]; - - // Handle editor sub-sections (document ID) - if (section === "editor") { - // Handle special cases for editor - let documentLabel: string; - if (subSection === "new") { - documentLabel = "New Note"; - } else { - documentLabel = documentTitle || subSection; - } - - breadcrumbs.push({ - label: t("documents"), - href: `/dashboard/${segments[1]}/documents`, - }); - breadcrumbs.push({ - label: sectionLabel, - href: `/dashboard/${segments[1]}/documents`, - }); - breadcrumbs.push({ label: documentLabel }); - return breadcrumbs; - } - - // Handle documents sub-sections - if (section === "documents") { - const documentLabels: Record = { - upload: t("upload_documents"), - webpage: t("add_webpages"), - }; - - const documentLabel = documentLabels[subSection] || subSection; - breadcrumbs.push({ - label: t("documents"), - href: `/dashboard/${segments[1]}/documents`, - }); - breadcrumbs.push({ label: documentLabel }); - return breadcrumbs; - } - - // Handle new-chat sub-sections (thread IDs) - // Show the chat title if available, otherwise fall back to "Chat" - if (section === "new-chat") { - const chatLabel = threadData?.title || t("chat") || "Chat"; - breadcrumbs.push({ - label: chatLabel, - }); - return breadcrumbs; - } - - // Handle other sub-sections - let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1); - const subSectionLabels: Record = { - upload: t("upload_documents"), - youtube: t("add_youtube"), - webpage: t("add_webpages"), - manage: t("manage"), - }; - - subSectionLabel = subSectionLabels[subSection] || subSectionLabel; - - breadcrumbs.push({ - label: sectionLabel, - href: `/dashboard/${segments[1]}/${section}`, - }); - breadcrumbs.push({ label: subSectionLabel }); - } else { - breadcrumbs.push({ label: sectionLabel }); - } - } - } - - return breadcrumbs; - }; - - const breadcrumbs = generateBreadcrumbs(pathname); - - if (breadcrumbs.length === 0) { - return null; // Don't show breadcrumbs for root dashboard - } - - return ( - - - {breadcrumbs.map((item, index) => ( - - - {index === breadcrumbs.length - 1 ? ( - {item.label} - ) : ( - {item.label} - )} - - {index < breadcrumbs.length - 1 && } - - ))} - - - ); -} diff --git a/surfsense_web/components/homepage/github-stars-badge.tsx b/surfsense_web/components/homepage/github-stars-badge.tsx new file mode 100644 index 000000000..27c4ef14f --- /dev/null +++ b/surfsense_web/components/homepage/github-stars-badge.tsx @@ -0,0 +1,363 @@ +"use client"; + +import { IconBrandGithub } from "@tabler/icons-react"; +import type { HTMLMotionProps, UseInViewOptions } from "motion/react"; +import { motion, useInView, useMotionValue, useSpring } from "motion/react"; +import * as React from "react"; +import { cn } from "@/lib/utils"; + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- +function getStrictContext(name?: string) { + const Context = React.createContext(undefined); + const Provider = ({ value, children }: { value: T; children?: React.ReactNode }) => ( + {children} + ); + const useSafeContext = () => { + const ctx = React.useContext(Context); + if (ctx === undefined) { + throw new Error(`useContext must be used within ${name ?? "a Provider"}`); + } + return ctx; + }; + return [Provider, useSafeContext] as const; +} + +interface UseIsInViewOptions { + inView?: boolean; + inViewOnce?: boolean; + inViewMargin?: UseInViewOptions["margin"]; +} + +function useIsInView( + ref: React.Ref, + options: UseIsInViewOptions = {} +) { + const { inView, inViewOnce = false, inViewMargin = "0px" } = options; + const localRef = React.useRef(null); + React.useImperativeHandle(ref, () => localRef.current as T); + const inViewResult = useInView(localRef, { + once: inViewOnce, + margin: inViewMargin, + }); + const isInView = !inView || inViewResult; + return { ref: localRef, isInView }; +} + +// --------------------------------------------------------------------------- +// Per-digit scrolling wheel +// --------------------------------------------------------------------------- +const ROLLING_ITEM_COUNT = 200; + +function DigitWheel({ + digit, + itemSize = 22, + delay = 0, + cycles = 5, + isRolling = false, + reverse = false, + className, + onSettled, +}: { + digit: number; + itemSize?: number; + delay?: number; + cycles?: number; + isRolling?: boolean; + reverse?: boolean; + className?: string; + onSettled?: () => void; +}) { + const sequence = React.useMemo(() => { + if (isRolling) { + return Array.from({ length: ROLLING_ITEM_COUNT }, (_, i) => ({ + id: `r${i}`, + value: i % 10, + })); + } + + const seq = Array.from({ length: cycles * 10 }, (_, i) => ({ + id: `s${i}`, + value: Math.floor(Math.random() * 10), + })); + const target = { id: "target", value: digit }; + if (reverse) { + seq.unshift(target); + } else { + seq.push(target); + } + return seq; + }, [digit, cycles, isRolling, reverse]); + + const maxOffset = (sequence.length - 1) * itemSize; + const endY = reverse ? 0 : -maxOffset; + + const rollingStartItem = React.useRef(Math.floor(Math.random() * 10)); + const startOffset = rollingStartItem.current * itemSize; + + const y = useMotionValue( + isRolling ? (reverse ? -(maxOffset - startOffset) : -startOffset) : reverse ? -maxOffset : 0 + ); + const ySpring = useSpring( + y, + isRolling ? { stiffness: 10000, damping: 500 } : { stiffness: 70, damping: 20 } + ); + const settledRef = React.useRef(false); + const wasRollingRef = React.useRef(isRolling); + + // Jump y to settling start position when transitioning from rolling → settled + React.useLayoutEffect(() => { + if (wasRollingRef.current && !isRolling) { + y.jump(reverse ? -maxOffset : 0); + } + wasRollingRef.current = isRolling; + }, [isRolling, reverse, maxOffset, y]); + + // Rolling: drive y continuously via RAF (stiff spring tracks it transparently) + React.useEffect(() => { + if (!isRolling) return; + + const cycleHeight = 10 * itemSize; + const msPerCycle = 1000; + let startTime: number | null = null; + let rafId: number; + + const tick = (time: number) => { + if (startTime === null) startTime = time; + const elapsed = time - startTime; + const speed = cycleHeight / msPerCycle; + const travel = elapsed * speed + startOffset; + + if (reverse) { + y.set(Math.min(-maxOffset + travel, 0)); + } else { + y.set(Math.max(-travel, -maxOffset)); + } + + rafId = requestAnimationFrame(tick); + }; + + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }, [isRolling, itemSize, reverse, y, maxOffset, startOffset]); + + // Settling: spring to endY after delay + React.useEffect(() => { + if (isRolling) return; + settledRef.current = false; + const timer = setTimeout(() => y.set(endY), delay); + return () => clearTimeout(timer); + }, [endY, y, delay, isRolling]); + + // Detect settled + React.useEffect(() => { + if (isRolling) return; + const unsub = ySpring.on("change", (latest) => { + if (!settledRef.current && Math.abs(latest - endY) < 0.5) { + settledRef.current = true; + onSettled?.(); + } + }); + return unsub; + }, [ySpring, endY, onSettled, isRolling]); + + return ( +
+ + {sequence.map((item) => ( +
+ {item.value} +
+ ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Animated star count with per-digit alternating wheels +// --------------------------------------------------------------------------- +const numberFormatter = new Intl.NumberFormat("en-US"); + +function AnimatedStarCount({ + value, + itemSize = 22, + isRolling = false, + animated = true, + className, + onComplete, +}: { + value: number; + itemSize?: number; + isRolling?: boolean; + animated?: boolean; + className?: string; + onComplete?: () => void; +}) { + const formatted = numberFormatter.format(value); + const chars = formatted.split(""); + + if (!animated) { + return ( +
+ {chars.map((char, idx) => ( +
= "0" && char <= "9" ? undefined : "0.3em", + }} + > + {char} +
+ ))} +
+ ); + } + + let totalDigits = 0; + for (const c of chars) { + if (c >= "0" && c <= "9") totalDigits++; + } + + const settledCount = React.useRef(0); + const completedRef = React.useRef(false); + + const handleDigitSettled = React.useCallback(() => { + settledCount.current++; + if (!completedRef.current && settledCount.current >= totalDigits) { + completedRef.current = true; + onComplete?.(); + } + }, [totalDigits, onComplete]); + + let digitIndex = 0; + let separatorIndex = 0; + + return ( +
+ {chars.map((char) => { + if (char < "0" || char > "9") { + const sepKey = `sep-${separatorIndex++}`; + return ( +
+ {char} +
+ ); + } + const digit = parseInt(char, 10); + const idx = digitIndex++; + return ( + + ); + })} +
+ ); +} + +// --------------------------------------------------------------------------- +// NavbarGitHubStars — the exported component +// --------------------------------------------------------------------------- +const ITEM_SIZE = 22; + +type NavbarGitHubStarsProps = { + username?: string; + repo?: string; + href?: string; + className?: string; +}; + +function NavbarGitHubStars({ + username = "MODSetter", + repo = "SurfSense", + href = "https://github.com/MODSetter/SurfSense", + className, +}: NavbarGitHubStarsProps) { + const [hasMounted, setHasMounted] = React.useState(false); + const [stars, setStars] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(true); + + React.useEffect(() => { + setHasMounted(true); + }, []); + + React.useEffect(() => { + const abortController = new AbortController(); + fetch(`https://api.github.com/repos/${username}/${repo}`, { + signal: abortController.signal, + }) + .then((res) => res.json()) + .then((data) => { + if (data && typeof data.stargazers_count === "number") { + setStars(data.stargazers_count); + } + }) + .catch((err) => { + if (err instanceof Error && err.name !== "AbortError") { + console.error("Error fetching stars:", err); + } + }) + .finally(() => setIsLoading(false)); + return () => abortController.abort(); + }, [username, repo]); + + return ( + + +
+ +
+
+ ); +} + +export { NavbarGitHubStars, type NavbarGitHubStarsProps }; diff --git a/surfsense_web/components/homepage/hero-section.tsx b/surfsense_web/components/homepage/hero-section.tsx index a1aa5ac4a..d8fd146e3 100644 --- a/surfsense_web/components/homepage/hero-section.tsx +++ b/surfsense_web/components/homepage/hero-section.tsx @@ -32,11 +32,24 @@ const GoogleLogo = ({ className }: { className?: string }) => ( ); +function useIsDesktop(breakpoint = 1024) { + const [isDesktop, setIsDesktop] = useState(false); + useEffect(() => { + const mql = window.matchMedia(`(min-width: ${breakpoint}px)`); + setIsDesktop(mql.matches); + const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches); + mql.addEventListener("change", handler); + return () => mql.removeEventListener("change", handler); + }, [breakpoint]); + return isDesktop; +} + export function HeroSection() { const containerRef = useRef(null); const parentRef = useRef(null); const heroVariant = useFeatureFlagVariantKey("notebooklm_superpowers_flag"); const isNotebookLMVariant = heroVariant === "superpowers"; + const isDesktop = useIsDesktop(); return (
- - - - + {isDesktop && ( + <> + + + + + + )}

{isNotebookLMVariant ? ( diff --git a/surfsense_web/components/homepage/navbar.tsx b/surfsense_web/components/homepage/navbar.tsx index ddf43e7eb..255134353 100644 --- a/surfsense_web/components/homepage/navbar.tsx +++ b/surfsense_web/components/homepage/navbar.tsx @@ -1,18 +1,12 @@ "use client"; -import { - IconBrandDiscord, - IconBrandGithub, - IconBrandReddit, - IconMenu2, - IconX, -} from "@tabler/icons-react"; +import { IconBrandDiscord, IconBrandReddit, IconMenu2, IconX } from "@tabler/icons-react"; import { AnimatePresence, motion } from "motion/react"; import Link from "next/link"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { SignInButton } from "@/components/auth/sign-in-button"; +import { NavbarGitHubStars } from "@/components/homepage/github-stars-badge"; import { Logo } from "@/components/Logo"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; -import { useGithubStars } from "@/hooks/use-github-stars"; import { cn } from "@/lib/utils"; export const Navbar = () => { @@ -38,7 +32,7 @@ export const Navbar = () => { }, []); return ( -
+
@@ -47,7 +41,6 @@ export const Navbar = () => { const DesktopNav = ({ navItems, isScrolled }: any) => { const [hovered, setHovered] = useState(null); - const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars(); return ( { @@ -103,21 +96,7 @@ const DesktopNav = ({ navItems, isScrolled }: any) => { > - - - {loadingGithubStars ? ( -
- ) : ( - - {githubStars} - - )} - +
@@ -127,10 +106,28 @@ const DesktopNav = ({ navItems, isScrolled }: any) => { const MobileNav = ({ navItems, isScrolled }: any) => { const [open, setOpen] = useState(false); - const { compactFormat: githubStars, loading: loadingGithubStars } = useGithubStars(); + const navRef = useRef(null); + + useEffect(() => { + if (!open) return; + + const handleClickOutside = (e: MouseEvent | TouchEvent) => { + if (navRef.current && !navRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchstart", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + }; + }, [open]); return ( { > - - - {loadingGithubStars ? ( -
- ) : ( - - {githubStars} - - )} - +

diff --git a/surfsense_web/components/inference-params-editor.tsx b/surfsense_web/components/inference-params-editor.tsx index b29275611..3764b1dac 100644 --- a/surfsense_web/components/inference-params-editor.tsx +++ b/surfsense_web/components/inference-params-editor.tsx @@ -1,6 +1,6 @@ "use client"; -import { Plus, Trash2 } from "lucide-react"; +import { Trash2 } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -76,7 +76,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa - + {PARAM_KEYS.map((key) => ( {key} @@ -104,7 +104,7 @@ export default function InferenceParamsEditor({ params, setParams }: InferencePa onClick={handleAdd} disabled={!selectedKey || value === ""} > - Add Parameter + Add Parameter diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index f378990a5..322e136c0 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -1,25 +1,28 @@ "use client"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { useAtomValue, useSetAtom } from "jotai"; -import { - AlertTriangle, - Inbox, - LogOut, - Megaphone, - PencilIcon, - SquareLibrary, - Trash2, -} from "lucide-react"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; +import { AlertTriangle, Inbox, Megaphone, SquareLibrary } from "lucide-react"; import { useParams, usePathname, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useTheme } from "next-themes"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom"; +import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms"; import { deleteSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { searchSpacesAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -32,6 +35,7 @@ import { import { Input } from "@/components/ui/input"; import { isPageLimitExceededMetadata } from "@/contracts/types/inbox.types"; import { useAnnouncements } from "@/hooks/use-announcements"; +import { useDocumentsProcessing } from "@/hooks/use-documents-processing"; import { useInbox } from "@/hooks/use-inbox"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { logout } from "@/lib/auth-utils"; @@ -46,7 +50,6 @@ import { LayoutShell } from "../ui/shell"; interface LayoutDataProviderProps { searchSpaceId: string; children: React.ReactNode; - breadcrumb?: React.ReactNode; } /** @@ -60,11 +63,7 @@ function formatInboxCount(count: number): string { return `${thousands}k+`; } -export function LayoutDataProvider({ - searchSpaceId, - children, - breadcrumb, -}: LayoutDataProviderProps) { +export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProviderProps) { const t = useTranslations("dashboard"); const tCommon = useTranslations("common"); const tSidebar = useTranslations("sidebar"); @@ -87,6 +86,10 @@ export function LayoutDataProvider({ // State for handling new chat navigation when router is out of sync const [pendingNewChat, setPendingNewChat] = useState(false); + // Key used to force-remount the page component (e.g. after deleting the active chat + // when the router is out of sync due to replaceState) + const [chatResetKey, setChatResetKey] = useState(0); + // Current IDs from URL, with fallback to atom for replaceState updates const currentChatId = params?.chat_id ? Number(Array.isArray(params.chat_id) ? params.chat_id[0] : params.chat_id) @@ -114,40 +117,27 @@ export function LayoutDataProvider({ const [isInboxSidebarOpen, setIsInboxSidebarOpen] = useState(false); const [isInboxDocked, setIsInboxDocked] = useState(false); + // Documents sidebar state (shared atom so Composer can toggle it) + const [isDocumentsSidebarOpen, setIsDocumentsSidebarOpen] = useAtom(documentsSidebarOpenAtom); + // Announcements sidebar state const [isAnnouncementsSidebarOpen, setIsAnnouncementsSidebarOpen] = useState(false); // Search space dialog state const [isCreateSearchSpaceDialogOpen, setIsCreateSearchSpaceDialogOpen] = useState(false); - // Inbox hooks - separate data sources for mentions and status tabs - // This ensures each tab has independent pagination and data loading + // Per-tab inbox hooks — each has independent API loading, pagination, + // and Electric live queries. The Electric sync shape is shared (client-level cache). const userId = user?.id ? String(user.id) : null; + const numericSpaceId = Number(searchSpaceId) || null; - const { - inboxItems: mentionItems, - unreadCount: mentionUnreadCount, - loading: mentionLoading, - loadingMore: mentionLoadingMore, - hasMore: mentionHasMore, - loadMore: mentionLoadMore, - markAsRead: markMentionAsRead, - markAllAsRead: markAllMentionsAsRead, - } = useInbox(userId, Number(searchSpaceId) || null, "new_mention"); + const commentsInbox = useInbox(userId, numericSpaceId, "comments"); + const statusInbox = useInbox(userId, numericSpaceId, "status"); - const { - inboxItems: statusItems, - unreadCount: allUnreadCount, - loading: statusLoading, - loadingMore: statusLoadingMore, - hasMore: statusHasMore, - loadMore: statusLoadMore, - markAsRead: markStatusAsRead, - markAllAsRead: markAllStatusAsRead, - } = useInbox(userId, Number(searchSpaceId) || null, null); + const totalUnreadCount = commentsInbox.unreadCount + statusInbox.unreadCount; - const totalUnreadCount = allUnreadCount; - const statusOnlyUnreadCount = Math.max(0, allUnreadCount - mentionUnreadCount); + // Document processing status — drives sidebar status indicator (spinner / check / error) + const documentsProcessingStatus = useDocumentsProcessing(numericSpaceId); // Track seen notification IDs to detect new page_limit_exceeded notifications const seenPageLimitNotifications = useRef>(new Set()); @@ -155,14 +145,12 @@ export function LayoutDataProvider({ // Effect to show toast for new page_limit_exceeded notifications useEffect(() => { - if (statusLoading) return; + if (statusInbox.loading) return; - // Get page_limit_exceeded notifications - const pageLimitNotifications = statusItems.filter( + const pageLimitNotifications = statusInbox.inboxItems.filter( (item) => item.type === "page_limit_exceeded" ); - // On initial load, just mark all as seen without showing toasts if (isInitialLoad.current) { for (const notification of pageLimitNotifications) { seenPageLimitNotifications.current.add(notification.id); @@ -171,16 +159,13 @@ export function LayoutDataProvider({ return; } - // Find new notifications (not yet seen) const newNotifications = pageLimitNotifications.filter( (notification) => !seenPageLimitNotifications.current.has(notification.id) ); - // Show toast for each new page_limit_exceeded notification for (const notification of newNotifications) { seenPageLimitNotifications.current.add(notification.id); - // Extract metadata for navigation const actionUrl = isPageLimitExceededMetadata(notification.metadata) ? notification.metadata.action_url : `/dashboard/${searchSpaceId}/more-pages`; @@ -195,24 +180,7 @@ export function LayoutDataProvider({ }, }); } - }, [statusItems, statusLoading, searchSpaceId, router]); - - // Unified mark as read that delegates to the correct hook - const markAsRead = useCallback( - async (id: number) => { - // Try both - one will succeed based on which list has the item - const mentionResult = await markMentionAsRead(id); - if (mentionResult) return true; - return markStatusAsRead(id); - }, - [markMentionAsRead, markStatusAsRead] - ); - - // Mark all as read for both types - const markAllAsRead = useCallback(async () => { - await Promise.all([markAllMentionsAsRead(), markAllStatusAsRead()]); - return true; - }, [markAllMentionsAsRead, markAllStatusAsRead]); + }, [statusInbox.inboxItems, statusInbox.loading, searchSpaceId, router]); // Delete dialogs state const [showDeleteChatDialog, setShowDeleteChatDialog] = useState(false); @@ -295,34 +263,35 @@ export function LayoutDataProvider({ // Navigation items const navItems: NavItem[] = useMemo( () => [ - { - title: "Documents", - url: `/dashboard/${searchSpaceId}/documents`, - icon: SquareLibrary, - isActive: pathname?.includes("/documents"), - }, { title: "Inbox", - url: "#inbox", // Special URL to indicate this is handled differently + url: "#inbox", icon: Inbox, isActive: isInboxSidebarOpen, badge: totalUnreadCount > 0 ? formatInboxCount(totalUnreadCount) : undefined, }, + { + title: "Documents", + url: "#documents", + icon: SquareLibrary, + isActive: isDocumentsSidebarOpen, + statusIndicator: documentsProcessingStatus, + }, { title: "Announcements", - url: "#announcements", // Special URL to indicate this is handled differently + url: "#announcements", icon: Megaphone, isActive: isAnnouncementsSidebarOpen, badge: announcementUnreadCount > 0 ? formatInboxCount(announcementUnreadCount) : undefined, }, ], [ - searchSpaceId, - pathname, isInboxSidebarOpen, + isDocumentsSidebarOpen, totalUnreadCount, isAnnouncementsSidebarOpen, announcementUnreadCount, + documentsProcessingStatus, ] ); @@ -339,12 +308,12 @@ export function LayoutDataProvider({ }, []); const handleUserSettings = useCallback(() => { - router.push("/dashboard/user/settings"); - }, [router]); + router.push(`/dashboard/${searchSpaceId}/user-settings?tab=profile`); + }, [router, searchSpaceId]); const handleSearchSpaceSettings = useCallback( (space: SearchSpace) => { - router.push(`/dashboard/${space.id}/settings?section=general`); + router.push(`/dashboard/${space.id}/settings?tab=general`); }, [router] ); @@ -415,10 +384,22 @@ export function LayoutDataProvider({ const handleNavItemClick = useCallback( (item: NavItem) => { - // Handle inbox specially - toggle sidebar instead of navigating if (item.url === "#inbox") { setIsInboxSidebarOpen((prev) => { if (!prev) { + setIsAllSharedChatsSidebarOpen(false); + setIsAllPrivateChatsSidebarOpen(false); + setIsDocumentsSidebarOpen(false); + setIsAnnouncementsSidebarOpen(false); + } + return !prev; + }); + return; + } + if (item.url === "#documents") { + setIsDocumentsSidebarOpen((prev) => { + if (!prev) { + setIsInboxSidebarOpen(false); setIsAllSharedChatsSidebarOpen(false); setIsAllPrivateChatsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false); @@ -427,13 +408,13 @@ export function LayoutDataProvider({ }); return; } - // Handle announcements specially - toggle sidebar instead of navigating if (item.url === "#announcements") { setIsAnnouncementsSidebarOpen((prev) => { if (!prev) { setIsInboxSidebarOpen(false); setIsAllSharedChatsSidebarOpen(false); setIsAllPrivateChatsSidebarOpen(false); + setIsDocumentsSidebarOpen(false); } return !prev; }); @@ -441,13 +422,7 @@ export function LayoutDataProvider({ } router.push(item.url); }, - [ - router, - setIsAllPrivateChatsSidebarOpen, - setIsAllSharedChatsSidebarOpen, - setIsAnnouncementsSidebarOpen, - setIsInboxSidebarOpen, - ] + [router, setIsDocumentsSidebarOpen] ); const handleNewChat = useCallback(() => { @@ -507,7 +482,7 @@ export function LayoutDataProvider({ ); const handleSettings = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/settings?section=general`); + router.push(`/dashboard/${searchSpaceId}/settings?tab=general`); }, [router, searchSpaceId]); const handleManageMembers = useCallback(() => { @@ -544,15 +519,17 @@ export function LayoutDataProvider({ setIsAllSharedChatsSidebarOpen(true); setIsAllPrivateChatsSidebarOpen(false); setIsInboxSidebarOpen(false); + setIsDocumentsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false); - }, []); + }, [setIsDocumentsSidebarOpen]); const handleViewAllPrivateChats = useCallback(() => { setIsAllPrivateChatsSidebarOpen(true); setIsAllSharedChatsSidebarOpen(false); setIsInboxSidebarOpen(false); + setIsDocumentsSidebarOpen(false); setIsAnnouncementsSidebarOpen(false); - }, []); + }, [setIsDocumentsSidebarOpen]); // Delete handlers const confirmDeleteChat = useCallback(async () => { @@ -562,7 +539,14 @@ export function LayoutDataProvider({ await deleteThread(chatToDelete.id); queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); if (currentChatId === chatToDelete.id) { - router.push(`/dashboard/${searchSpaceId}/new-chat`); + resetCurrentThread(); + const isOutOfSync = currentThreadState.id !== null && !params?.chat_id; + if (isOutOfSync) { + window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`); + setChatResetKey((k) => k + 1); + } else { + router.push(`/dashboard/${searchSpaceId}/new-chat`); + } } } catch (error) { console.error("Error deleting thread:", error); @@ -571,7 +555,16 @@ export function LayoutDataProvider({ setShowDeleteChatDialog(false); setChatToDelete(null); } - }, [chatToDelete, queryClient, searchSpaceId, router, currentChatId]); + }, [ + chatToDelete, + queryClient, + searchSpaceId, + resetCurrentThread, + currentChatId, + currentThreadState.id, + params?.chat_id, + router, + ]); // Rename handler const confirmRenameChat = useCallback(async () => { @@ -583,10 +576,6 @@ export function LayoutDataProvider({ queryClient.invalidateQueries({ queryKey: ["threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["all-threads", searchSpaceId] }); queryClient.invalidateQueries({ queryKey: ["search-threads", searchSpaceId] }); - // Invalidate thread detail for breadcrumb update - queryClient.invalidateQueries({ - queryKey: ["threads", searchSpaceId, "detail", String(chatToRename.id)], - }); } catch (error) { console.error("Error renaming thread:", error); toast.error(tSidebar("error_renaming_chat") || "Failed to rename chat"); @@ -641,7 +630,6 @@ export function LayoutDataProvider({ onUserSettings={handleUserSettings} onLogout={handleLogout} pageUsage={pageUsage} - breadcrumb={breadcrumb} theme={theme} setTheme={setTheme} isChatPage={isChatPage} @@ -649,26 +637,27 @@ export function LayoutDataProvider({ inbox={{ isOpen: isInboxSidebarOpen, onOpenChange: setIsInboxSidebarOpen, - // Separate data sources for each tab - mentions: { - items: mentionItems, - unreadCount: mentionUnreadCount, - loading: mentionLoading, - loadingMore: mentionLoadingMore, - hasMore: mentionHasMore, - loadMore: mentionLoadMore, + totalUnreadCount, + comments: { + items: commentsInbox.inboxItems, + unreadCount: commentsInbox.unreadCount, + loading: commentsInbox.loading, + loadingMore: commentsInbox.loadingMore, + hasMore: commentsInbox.hasMore, + loadMore: commentsInbox.loadMore, + markAsRead: commentsInbox.markAsRead, + markAllAsRead: commentsInbox.markAllAsRead, }, status: { - items: statusItems, - unreadCount: statusOnlyUnreadCount, - loading: statusLoading, - loadingMore: statusLoadingMore, - hasMore: statusHasMore, - loadMore: statusLoadMore, + items: statusInbox.inboxItems, + unreadCount: statusInbox.unreadCount, + loading: statusInbox.loading, + loadingMore: statusInbox.loadingMore, + hasMore: statusInbox.hasMore, + loadMore: statusInbox.loadMore, + markAsRead: statusInbox.markAsRead, + markAllAsRead: statusInbox.markAllAsRead, }, - totalUnreadCount, - markAsRead, - markAllAsRead, isDocked: isInboxDocked, onDockedChange: setIsInboxDocked, }} @@ -686,36 +675,33 @@ export function LayoutDataProvider({ onOpenChange: setIsAllPrivateChatsSidebarOpen, searchSpaceId, }} + documentsPanel={{ + open: isDocumentsSidebarOpen, + onOpenChange: setIsDocumentsSidebarOpen, + }} > - {children} + {children} {/* Delete Chat Dialog */} - - - - - - {t("delete_chat")} - - + + + + {t("delete_chat")} + {t("delete_chat_confirm")} {chatToDelete?.name}?{" "} {t("action_cannot_undone")} - - - - - - - - + + + + {/* Rename Chat Dialog */} @@ -756,7 +739,7 @@ export function LayoutDataProvider({ /> @@ -784,30 +764,25 @@ export function LayoutDataProvider({ {/* Delete Search Space Dialog */} - - - - - - {t("delete_search_space")} - - + + + + {t("delete_search_space")} + {t("delete_space_confirm", { name: searchSpaceToDelete?.name || "" })} - - - - - - - - + + + + {/* Leave Search Space Dialog */} - - - - - - {t("leave_title")} - - + + + + {t("leave_title")} + {t("leave_confirm", { name: searchSpaceToLeave?.name || "" })} - - - - - - - - + + + + {/* Create Search Space Dialog */} - + - {/* Actions dropdown */} -
- + {/* Actions dropdown - trigger hidden on mobile, long-press opens it instead */} +
+ - + {onRename && ( { @@ -105,7 +127,6 @@ export function ChatListItem({ e.stopPropagation(); onDelete(); }} - className="text-destructive focus:text-destructive" > {t("delete")} diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx new file mode 100644 index 000000000..1a21c4b54 --- /dev/null +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -0,0 +1,212 @@ +"use client"; + +import { useAtom, useAtomValue } from "jotai"; +import { ChevronLeft } from "lucide-react"; +import { useParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { DocumentsFilters } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters"; +import { + DocumentsTableShell, + type SortKey, +} from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell"; +import { sidebarSelectedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom"; +import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; +import { Button } from "@/components/ui/button"; +import type { DocumentTypeEnum } from "@/contracts/types/document.types"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; +import { useDocumentSearch } from "@/hooks/use-document-search"; +import { useDocuments } from "@/hooks/use-documents"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; + +interface DocumentsSidebarProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) { + const t = useTranslations("documents"); + const tSidebar = useTranslations("sidebar"); + const params = useParams(); + const isMobile = !useMediaQuery("(min-width: 640px)"); + const searchSpaceId = Number(params.search_space_id); + + const [search, setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 250); + const [activeTypes, setActiveTypes] = useState([]); + const [sortKey, setSortKey] = useState("created_at"); + const [sortDesc, setSortDesc] = useState(true); + const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); + + const [sidebarDocs, setSidebarDocs] = useAtom(sidebarSelectedDocumentsAtom); + const mentionedDocIds = useMemo(() => new Set(sidebarDocs.map((d) => d.id)), [sidebarDocs]); + + const handleToggleChatMention = useCallback( + (doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => { + if (isMentioned) { + setSidebarDocs((prev) => prev.filter((d) => d.id !== doc.id)); + } else { + setSidebarDocs((prev) => { + if (prev.some((d) => d.id === doc.id)) return prev; + return [ + ...prev, + { id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum }, + ]; + }); + } + }, + [setSidebarDocs] + ); + + const isSearchMode = !!debouncedSearch.trim(); + + const { + documents: realtimeDocuments, + typeCounts: realtimeTypeCounts, + loading: realtimeLoading, + loadingMore: realtimeLoadingMore, + hasMore: realtimeHasMore, + loadMore: realtimeLoadMore, + error: realtimeError, + } = useDocuments(searchSpaceId, activeTypes, sortKey, sortDesc ? "desc" : "asc"); + + const { + documents: searchDocuments, + loading: searchLoading, + loadingMore: searchLoadingMore, + hasMore: searchHasMore, + loadMore: searchLoadMore, + error: searchError, + removeItems: searchRemoveItems, + } = useDocumentSearch(searchSpaceId, debouncedSearch, activeTypes, isSearchMode && open); + + const displayDocs = isSearchMode ? searchDocuments : realtimeDocuments; + const loading = isSearchMode ? searchLoading : realtimeLoading; + const error = isSearchMode ? searchError : !!realtimeError; + const hasMore = isSearchMode ? searchHasMore : realtimeHasMore; + const loadingMore = isSearchMode ? searchLoadingMore : realtimeLoadingMore; + const onLoadMore = isSearchMode ? searchLoadMore : realtimeLoadMore; + + const onToggleType = (type: DocumentTypeEnum, checked: boolean) => { + setActiveTypes((prev) => { + if (checked) { + return prev.includes(type) ? prev : [...prev, type]; + } + return prev.filter((t) => t !== type); + }); + }; + + const handleDeleteDocument = useCallback( + async (id: number): Promise => { + try { + await deleteDocumentMutation({ id }); + toast.success(t("delete_success") || "Document deleted"); + setSidebarDocs((prev) => prev.filter((d) => d.id !== id)); + if (isSearchMode) { + searchRemoveItems([id]); + } + return true; + } catch (e) { + console.error("Error deleting document:", e); + return false; + } + }, + [deleteDocumentMutation, isSearchMode, t, searchRemoveItems, setSidebarDocs] + ); + + const sortKeyRef = useRef(sortKey); + const sortDescRef = useRef(sortDesc); + sortKeyRef.current = sortKey; + sortDescRef.current = sortDesc; + + const handleSortChange = useCallback((key: SortKey) => { + const currentKey = sortKeyRef.current; + const currentDesc = sortDescRef.current; + + if (currentKey === key && currentDesc) { + setSortKey("created_at"); + setSortDesc(true); + } else if (currentKey === key) { + setSortDesc(true); + } else { + setSortKey(key); + setSortDesc(false); + } + }, []); + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && open) { + onOpenChange(false); + } + }; + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [open, onOpenChange]); + + const documentsContent = ( + <> +
+
+
+ {isMobile && ( + + )} +

{t("title") || "Documents"}

+
+
+
+ +
+
+ +
+ + onOpenChange(false)} + isSearchMode={isSearchMode || activeTypes.length > 0} + /> +
+ + ); + + return ( + + {documentsContent} + + ); +} diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 9fc0a121a..de5218ffa 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -22,6 +22,7 @@ import { import { useParams, useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { getDocumentTypeLabel } from "@/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon"; import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -49,6 +50,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { isCommentReplyMetadata, isConnectorIndexingMetadata, + isDocumentProcessingMetadata, isNewMentionMetadata, isPageLimitExceededMetadata, } from "@/contracts/types/inbox.types"; @@ -60,9 +62,6 @@ import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cn } from "@/lib/utils"; import { SidebarSlideOutPanel } from "./SidebarSlideOutPanel"; -/** - * Get initials from name or email for avatar fallback - */ function getInitials(name: string | null | undefined, email: string | null | undefined): string { if (name) { return name @@ -79,9 +78,6 @@ function getInitials(name: string | null | undefined, email: string | null | und return "U"; } -/** - * Format count for display: shows numbers up to 999, then "1k+", "2k+", etc. - */ function formatInboxCount(count: number): string { if (count <= 999) { return count.toString(); @@ -90,9 +86,6 @@ function formatInboxCount(count: number): string { return `${thousands}k+`; } -/** - * Get display name for connector type - */ function getConnectorTypeDisplayName(connectorType: string): string { const displayNames: Record = { GITHUB_CONNECTOR: "GitHub", @@ -135,44 +128,36 @@ function getConnectorTypeDisplayName(connectorType: string): string { } type InboxTab = "comments" | "status"; -type InboxFilter = "all" | "unread"; +type InboxFilter = "all" | "unread" | "errors"; -// Tab-specific data source with independent pagination interface TabDataSource { items: InboxItem[]; unreadCount: number; loading: boolean; - loadingMore?: boolean; - hasMore?: boolean; - loadMore?: () => void; + loadingMore: boolean; + hasMore: boolean; + loadMore: () => void; + markAsRead: (id: number) => Promise; + markAllAsRead: () => Promise; } interface InboxSidebarProps { open: boolean; onOpenChange: (open: boolean) => void; - /** Mentions tab data source with independent pagination */ - mentions: TabDataSource; - /** Status tab data source with independent pagination */ + comments: TabDataSource; status: TabDataSource; - /** Combined unread count for mark all as read */ totalUnreadCount: number; - markAsRead: (id: number) => Promise; - markAllAsRead: () => Promise; onCloseMobileSidebar?: () => void; - /** Whether the inbox is docked (permanent) or floating */ isDocked?: boolean; - /** Callback to toggle docked state */ onDockedChange?: (docked: boolean) => void; } export function InboxSidebar({ open, onOpenChange, - mentions, + comments, status, totalUnreadCount, - markAsRead, - markAllAsRead, onCloseMobileSidebar, isDocked = false, onDockedChange, @@ -183,9 +168,7 @@ export function InboxSidebar({ const isMobile = !useMediaQuery("(min-width: 640px)"); const searchSpaceId = params?.search_space_id ? Number(params.search_space_id) : null; - // Comments collapsed state (desktop only, when docked) const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom); - // Target comment for navigation - also ensures comments panel is visible const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [searchQuery, setSearchQuery] = useState(""); @@ -193,11 +176,9 @@ export function InboxSidebar({ const isSearchMode = !!debouncedSearch.trim(); const [activeTab, setActiveTab] = useState("comments"); const [activeFilter, setActiveFilter] = useState("all"); - const [selectedConnector, setSelectedConnector] = useState(null); + const [selectedSource, setSelectedSource] = useState(null); const [mounted, setMounted] = useState(false); - // Dropdown state for filter menu (desktop only) const [openDropdown, setOpenDropdown] = useState<"filter" | null>(null); - // Scroll shadow state for connector list const [connectorScrollPos, setConnectorScrollPos] = useState<"top" | "middle" | "bottom">("top"); const handleConnectorScroll = useCallback((e: React.UIEvent) => { const el = e.currentTarget; @@ -205,15 +186,12 @@ export function InboxSidebar({ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; setConnectorScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); }, []); - // Drawer state for filter menu (mobile only) const [filterDrawerOpen, setFilterDrawerOpen] = useState(false); const [markingAsReadId, setMarkingAsReadId] = useState(null); - // Prefetch trigger ref - placed on item near the end const prefetchTriggerRef = useRef(null); - // Server-side search query (enabled only when user is typing a search) - // Determines which notification types to search based on active tab + // Server-side search query const searchTypeFilter = activeTab === "comments" ? ("new_mention" as const) : undefined; const { data: searchResponse, isLoading: isSearchLoading } = useQuery({ queryKey: cacheKeys.notifications.search(searchSpaceId, debouncedSearch.trim(), activeTab), @@ -226,7 +204,7 @@ export function InboxSidebar({ limit: 50, }, }), - staleTime: 30 * 1000, // 30 seconds (search results don't need to be super fresh) + staleTime: 30 * 1000, enabled: isSearchMode && open, }); @@ -244,129 +222,128 @@ export function InboxSidebar({ return () => document.removeEventListener("keydown", handleEscape); }, [open, onOpenChange]); - // Only lock body scroll on mobile when inbox is open useEffect(() => { if (!open || !isMobile) return; - - // Store original overflow to restore on cleanup const originalOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; - return () => { document.body.style.overflow = originalOverflow; }; }, [open, isMobile]); - // Reset connector filter when switching away from status tab useEffect(() => { if (activeTab !== "status") { - setSelectedConnector(null); + setSelectedSource(null); } }, [activeTab]); - // Each tab uses its own data source for independent pagination - // Comments tab: uses mentions data source (fetches only mention/reply types from server) - const commentsItems = mentions.items; + // Active tab's data source — fully independent loading, pagination, and counts + const activeSource = activeTab === "comments" ? comments : status; - // Status tab: filters status data source (fetches all types) to status-specific types - const statusItems = useMemo( - () => - status.items.filter( - (item) => - item.type === "connector_indexing" || - item.type === "document_processing" || - item.type === "page_limit_exceeded" || - item.type === "connector_deletion" - ), - [status.items] + // Fetch source types for the status tab filter + const { data: sourceTypesData } = useQuery({ + queryKey: cacheKeys.notifications.sourceTypes(searchSpaceId), + queryFn: () => notificationsApiService.getSourceTypes(searchSpaceId ?? undefined), + staleTime: 60 * 1000, + enabled: open && activeTab === "status", + }); + + const statusSourceOptions = useMemo(() => { + if (!sourceTypesData?.sources) return []; + + return sourceTypesData.sources.map((source) => ({ + key: source.key, + type: source.type, + category: source.category, + displayName: + source.category === "connector" + ? getConnectorTypeDisplayName(source.type) + : getDocumentTypeLabel(source.type), + })); + }, [sourceTypesData]); + + // Client-side filter: source type + const matchesSourceFilter = useCallback( + (item: InboxItem): boolean => { + if (!selectedSource) return true; + if (selectedSource.startsWith("connector:")) { + const connectorType = selectedSource.slice("connector:".length); + return ( + item.type === "connector_indexing" && + isConnectorIndexingMetadata(item.metadata) && + item.metadata.connector_type === connectorType + ); + } + if (selectedSource.startsWith("doctype:")) { + const docType = selectedSource.slice("doctype:".length); + return ( + item.type === "document_processing" && + isDocumentProcessingMetadata(item.metadata) && + item.metadata.document_type === docType + ); + } + return true; + }, + [selectedSource] ); - // Pagination switches based on active tab - const loading = activeTab === "comments" ? mentions.loading : status.loading; - const loadingMore = - activeTab === "comments" ? (mentions.loadingMore ?? false) : (status.loadingMore ?? false); - const hasMore = - activeTab === "comments" ? (mentions.hasMore ?? false) : (status.hasMore ?? false); - const loadMore = activeTab === "comments" ? mentions.loadMore : status.loadMore; + // Client-side filter: unread / errors + const matchesActiveFilter = useCallback( + (item: InboxItem): boolean => { + if (activeFilter === "unread") return !item.read; + if (activeFilter === "errors") { + if (item.type === "page_limit_exceeded") return true; + const meta = item.metadata as Record | undefined; + return typeof meta?.status === "string" && meta.status === "failed"; + } + return true; + }, + [activeFilter] + ); - // Get unique connector types from status items for filtering - const uniqueConnectorTypes = useMemo(() => { - const connectorTypes = new Set(); - - statusItems - .filter((item) => item.type === "connector_indexing") - .forEach((item) => { - // Use type guard for safe metadata access - if (isConnectorIndexingMetadata(item.metadata)) { - connectorTypes.add(item.metadata.connector_type); - } - }); - - return Array.from(connectorTypes).map((type) => ({ - type, - displayName: getConnectorTypeDisplayName(type), - })); - }, [statusItems]); - - // Get items for current tab - const displayItems = activeTab === "comments" ? commentsItems : statusItems; - - // Filter items based on filter type, connector filter, and search mode - // When searching: use server-side API results (searches ALL notifications) - // When not searching: use Electric real-time items (fast, local) + // Two data paths: search mode (API) or default (per-tab data source) const filteredItems = useMemo(() => { - // In search mode, use API results - let items: InboxItem[] = isSearchMode ? (searchResponse?.items ?? []) : displayItems; + let tabItems: InboxItem[]; - // For status tab search results, filter to status-specific types - if (isSearchMode && activeTab === "status") { - items = items.filter( - (item) => - item.type === "connector_indexing" || - item.type === "document_processing" || - item.type === "page_limit_exceeded" || - item.type === "connector_deletion" - ); + if (isSearchMode) { + tabItems = searchResponse?.items ?? []; + } else { + tabItems = activeSource.items; } - // Apply read/unread filter - if (activeFilter === "unread") { - items = items.filter((item) => !item.read); + let result = tabItems; + if (activeFilter !== "all") { + result = result.filter(matchesActiveFilter); + } + if (activeTab === "status" && selectedSource) { + result = result.filter(matchesSourceFilter); } - // Apply connector filter (only for status tab) - if (activeTab === "status" && selectedConnector) { - items = items.filter((item) => { - if (item.type === "connector_indexing") { - // Use type guard for safe metadata access - if (isConnectorIndexingMetadata(item.metadata)) { - return item.metadata.connector_type === selectedConnector; - } - return false; - } - return false; // Hide document_processing when a specific connector is selected - }); - } + return result; + }, [ + isSearchMode, + searchResponse, + activeSource.items, + activeTab, + activeFilter, + selectedSource, + matchesActiveFilter, + matchesSourceFilter, + ]); - return items; - }, [displayItems, searchResponse, isSearchMode, activeFilter, activeTab, selectedConnector]); - - // Intersection Observer for infinite scroll with prefetching - // Re-runs when active tab changes so each tab gets its own pagination - // Disabled during server-side search (search results are not paginated via infinite scroll) + // Infinite scroll — uses active tab's pagination useEffect(() => { - if (!loadMore || !hasMore || loadingMore || !open || isSearchMode) return; + if (!activeSource.hasMore || activeSource.loadingMore || !open || isSearchMode) return; const observer = new IntersectionObserver( (entries) => { - // When trigger element is visible, load more if (entries[0]?.isIntersecting) { - loadMore(); + activeSource.loadMore(); } }, { - root: null, // viewport - rootMargin: "100px", // Start loading 100px before visible + root: null, + rootMargin: "100px", threshold: 0, } ); @@ -376,17 +353,13 @@ export function InboxSidebar({ } return () => observer.disconnect(); - }, [loadMore, hasMore, loadingMore, open, isSearchMode, activeTab]); - - // Unread counts from server-side accurate totals (passed via props) - const unreadCommentsCount = mentions.unreadCount; - const unreadStatusCount = status.unreadCount; + }, [activeSource.hasMore, activeSource.loadingMore, activeSource.loadMore, open, isSearchMode]); const handleItemClick = useCallback( async (item: InboxItem) => { if (!item.read) { setMarkingAsReadId(item.id); - await markAsRead(item.id); + await activeSource.markAsRead(item.id); setMarkingAsReadId(null); } @@ -427,7 +400,6 @@ export function InboxSidebar({ } } } else if (item.type === "page_limit_exceeded") { - // Navigate to the upgrade/more-pages page if (isPageLimitExceededMetadata(item.metadata)) { const actionUrl = item.metadata.action_url; if (actionUrl) { @@ -438,12 +410,12 @@ export function InboxSidebar({ } } }, - [markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId] + [activeSource.markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId] ); const handleMarkAllAsRead = useCallback(async () => { - await markAllAsRead(); - }, [markAllAsRead]); + await Promise.all([comments.markAllAsRead(), status.markAllAsRead()]); + }, [comments.markAllAsRead, status.markAllAsRead]); const handleClearSearch = useCallback(() => { setSearchQuery(""); @@ -469,7 +441,6 @@ export function InboxSidebar({ }; const getStatusIcon = (item: InboxItem) => { - // For mentions and comment replies, show the author's avatar if (item.type === "new_mention" || item.type === "comment_reply") { const metadata = item.type === "new_mention" @@ -501,7 +472,6 @@ export function InboxSidebar({ ); } - // For page limit exceeded, show a warning icon with amber/orange color if (item.type === "page_limit_exceeded") { return (
@@ -510,8 +480,6 @@ export function InboxSidebar({ ); } - // For status items (connector/document), show status icons - // Safely access status from metadata const metadata = item.metadata as Record; const status = typeof metadata?.status === "string" ? metadata.status : undefined; @@ -558,13 +526,13 @@ export function InboxSidebar({ if (!mounted) return null; - // Shared content component for both docked and floating modes + const isLoading = isSearchMode ? isSearchLoading : activeSource.loading; + const inboxContent = ( <>
- {/* Back button - mobile only */} {isMobile && (
- {/* Mobile: Button that opens bottom drawer */} {isMobile ? ( <> + {activeTab === "status" && ( + + )}
- {/* Connectors section - only for status tab */} - {activeTab === "status" && uniqueConnectorTypes.length > 0 && ( + {activeTab === "status" && statusSourceOptions.length > 0 && (

- {t("connectors") || "Connectors"} + {t("sources") || "Sources"}

- {uniqueConnectorTypes.map((connector) => ( + {statusSourceOptions.map((source) => ( ))}
@@ -709,7 +693,6 @@ export function InboxSidebar({ ) : ( - /* Desktop: Dropdown menu */ setOpenDropdown(isOpen ? "filter" : null)} @@ -727,7 +710,10 @@ export function InboxSidebar({ {t("filter") || "Filter"} @@ -752,13 +738,25 @@ export function InboxSidebar({ {activeFilter === "unread" && } - {activeTab === "status" && uniqueConnectorTypes.length > 0 && ( + {activeTab === "status" && ( + setActiveFilter("errors")} + className="flex items-center justify-between" + > + + + {t("errors_only") || "Errors only"} + + {activeFilter === "errors" && } + + )} + {activeTab === "status" && statusSourceOptions.length > 0 && ( <> - {t("connectors") || "Connectors"} + {t("sources") || "Sources"}
setSelectedConnector(null)} + onClick={() => setSelectedSource(null)} className="flex items-center justify-between" > - {t("all_connectors") || "All connectors"} + {t("all_sources") || "All sources"} - {selectedConnector === null && } + {selectedSource === null && } - {uniqueConnectorTypes.map((connector) => ( + {statusSourceOptions.map((source) => ( setSelectedConnector(connector.type)} + key={source.key} + onClick={() => setSelectedSource(source.key)} className="flex items-center justify-between" > - {getConnectorIcon(connector.type, "h-4 w-4")} - {connector.displayName} + {getConnectorIcon(source.type, "h-4 w-4")} + {source.displayName} - {selectedConnector === connector.type && } + {selectedSource === source.key && } ))}
@@ -824,7 +822,6 @@ export function InboxSidebar({ )} - {/* Dock/Undock button - desktop only */} {!isMobile && onDockedChange && ( @@ -834,12 +831,10 @@ export function InboxSidebar({ className="h-8 w-8 rounded-full" onClick={() => { if (isDocked) { - // Collapse: show comments immediately, then close inbox setCommentsCollapsed(false); onDockedChange(false); onOpenChange(false); } else { - // Expand: hide comments immediately setCommentsCollapsed(true); onDockedChange(true); } @@ -886,7 +881,13 @@ export function InboxSidebar({ setActiveTab(value as InboxTab)} + onValueChange={(value) => { + const tab = value as InboxTab; + setActiveTab(tab); + if (tab !== "status" && activeFilter === "errors") { + setActiveFilter("all"); + } + }} className="shrink-0 mx-4" > @@ -898,7 +899,7 @@ export function InboxSidebar({ {t("comments") || "Comments"} - {formatInboxCount(unreadCommentsCount)} + {formatInboxCount(comments.unreadCount)} @@ -910,7 +911,7 @@ export function InboxSidebar({ {t("status") || "Status"} - {formatInboxCount(unreadStatusCount)} + {formatInboxCount(status.unreadCount)} @@ -918,11 +919,10 @@ export function InboxSidebar({
- {(isSearchMode ? isSearchLoading : loading) ? ( + {isLoading ? (
{activeTab === "comments" - ? /* Comments skeleton: avatar + two-line text + time */ - [85, 60, 90, 70, 50, 75].map((titleWidth, i) => ( + ? [85, 60, 90, 70, 50, 75].map((titleWidth, i) => (
)) - : /* Status skeleton: status icon circle + two-line text + time */ - [75, 90, 55, 80, 65, 85].map((titleWidth, i) => ( + : [75, 90, 55, 80, 65, 85].map((titleWidth, i) => (
{filteredItems.map((item, index) => { const isMarkingAsRead = markingAsReadId === item.id; - // Place prefetch trigger on 5th item from end (only when not searching) const isPrefetchTrigger = - !isSearchMode && hasMore && index === filteredItems.length - 5; + !isSearchMode && activeSource.hasMore && index === filteredItems.length - 5; return (
)} - {/* Time and unread dot - fixed width to prevent content shift */}
{formatTime(item.created_at)} @@ -1038,12 +1035,10 @@ export function InboxSidebar({
); })} - {/* Fallback trigger at the very end if less than 5 items and not searching */} - {!isSearchMode && filteredItems.length < 5 && hasMore && ( + {!isSearchMode && filteredItems.length < 5 && activeSource.hasMore && (
)} - {/* Loading more skeletons at the bottom during infinite scroll */} - {loadingMore && + {activeSource.loadingMore && (activeTab === "comments" ? [80, 60, 90].map((titleWidth, i) => (
); - // DOCKED MODE: Render as a static flex child (no animation, no click-away) if (isDocked && open && !isMobile) { return (
- + @@ -378,7 +416,30 @@ export function SidebarUserProfile({ - + + + + {t("learn_more")} + + + + {LEARN_MORE_LINKS.map((link) => ( + + + {t(link.key)} + + + + ))} + +

+ v{APP_VERSION} +

+
+
+
+ + {isLoggingOut ? : } diff --git a/surfsense_web/components/layout/ui/sidebar/index.ts b/surfsense_web/components/layout/ui/sidebar/index.ts index e7590b2d7..4da08ef50 100644 --- a/surfsense_web/components/layout/ui/sidebar/index.ts +++ b/surfsense_web/components/layout/ui/sidebar/index.ts @@ -2,6 +2,7 @@ export { AllPrivateChatsSidebar } from "./AllPrivateChatsSidebar"; export { AllSharedChatsSidebar } from "./AllSharedChatsSidebar"; export { AnnouncementsSidebar } from "./AnnouncementsSidebar"; export { ChatListItem } from "./ChatListItem"; +export { DocumentsSidebar } from "./DocumentsSidebar"; export { InboxSidebar } from "./InboxSidebar"; export { MobileSidebar, MobileSidebarTrigger } from "./MobileSidebar"; export { NavSection } from "./NavSection"; diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index 2f1d9d845..45a07d5a1 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -7,38 +7,39 @@ import type { ImageGenerationConfig, NewLLMConfigPublic, } from "@/contracts/types/new-llm-config.types"; -import { ImageConfigSidebar } from "./image-config-sidebar"; -import { ModelConfigSidebar } from "./model-config-sidebar"; +import { ImageConfigDialog } from "./image-config-dialog"; +import { ModelConfigDialog } from "./model-config-dialog"; import { ModelSelector } from "./model-selector"; interface ChatHeaderProps { searchSpaceId: number; + className?: string; } -export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { - // LLM config sidebar state - const [sidebarOpen, setSidebarOpen] = useState(false); +export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) { + // LLM config dialog state + const [dialogOpen, setDialogOpen] = useState(false); const [selectedConfig, setSelectedConfig] = useState< NewLLMConfigPublic | GlobalNewLLMConfig | null >(null); const [isGlobal, setIsGlobal] = useState(false); - const [sidebarMode, setSidebarMode] = useState<"create" | "edit" | "view">("view"); + const [dialogMode, setDialogMode] = useState<"create" | "edit" | "view">("view"); - // Image config sidebar state - const [imageSidebarOpen, setImageSidebarOpen] = useState(false); + // Image config dialog state + const [imageDialogOpen, setImageDialogOpen] = useState(false); const [selectedImageConfig, setSelectedImageConfig] = useState< ImageGenerationConfig | GlobalImageGenConfig | null >(null); const [isImageGlobal, setIsImageGlobal] = useState(false); - const [imageSidebarMode, setImageSidebarMode] = useState<"create" | "edit" | "view">("view"); + const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view"); // LLM handlers const handleEditLLMConfig = useCallback( (config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => { setSelectedConfig(config); setIsGlobal(global); - setSidebarMode(global ? "view" : "edit"); - setSidebarOpen(true); + setDialogMode(global ? "view" : "edit"); + setDialogOpen(true); }, [] ); @@ -46,12 +47,12 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { const handleAddNewLLM = useCallback(() => { setSelectedConfig(null); setIsGlobal(false); - setSidebarMode("create"); - setSidebarOpen(true); + setDialogMode("create"); + setDialogOpen(true); }, []); - const handleSidebarClose = useCallback((open: boolean) => { - setSidebarOpen(open); + const handleDialogClose = useCallback((open: boolean) => { + setDialogOpen(open); if (!open) setSelectedConfig(null); }, []); @@ -59,22 +60,22 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { const handleAddImageModel = useCallback(() => { setSelectedImageConfig(null); setIsImageGlobal(false); - setImageSidebarMode("create"); - setImageSidebarOpen(true); + setImageDialogMode("create"); + setImageDialogOpen(true); }, []); const handleEditImageConfig = useCallback( (config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => { setSelectedImageConfig(config); setIsImageGlobal(global); - setImageSidebarMode(global ? "view" : "edit"); - setImageSidebarOpen(true); + setImageDialogMode(global ? "view" : "edit"); + setImageDialogOpen(true); }, [] ); - const handleImageSidebarClose = useCallback((open: boolean) => { - setImageSidebarOpen(open); + const handleImageDialogClose = useCallback((open: boolean) => { + setImageDialogOpen(open); if (!open) setSelectedImageConfig(null); }, []); @@ -85,22 +86,23 @@ export function ChatHeader({ searchSpaceId }: ChatHeaderProps) { onAddNewLLM={handleAddNewLLM} onEditImage={handleEditImageConfig} onAddNewImage={handleAddImageModel} + className={className} /> - -
); diff --git a/surfsense_web/components/new-chat/chat-share-button.tsx b/surfsense_web/components/new-chat/chat-share-button.tsx index bfb6d91fc..b0f53d06b 100644 --- a/surfsense_web/components/new-chat/chat-share-button.tsx +++ b/surfsense_web/components/new-chat/chat-share-button.tsx @@ -72,12 +72,15 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS // Query to check if thread has public snapshots const { data: snapshotsData } = useQuery({ queryKey: ["thread-snapshots", thread?.id], - queryFn: () => chatThreadsApiService.listPublicChatSnapshots({ thread_id: thread!.id }), + queryFn: () => { + const id = thread?.id; + if (id == null) throw new Error("Missing thread id"); + return chatThreadsApiService.listPublicChatSnapshots({ thread_id: id }); + }, enabled: !!thread?.id, staleTime: 30000, // Cache for 30 seconds }); const hasPublicSnapshots = (snapshotsData?.snapshots?.length ?? 0) > 0; - const snapshotCount = snapshotsData?.snapshots?.length ?? 0; // Use Jotai visibility if available (synced from chat page), otherwise fall back to thread prop const currentVisibility = currentThreadState.visibility ?? thread?.visibility ?? "PRIVATE"; @@ -145,18 +148,14 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS - - {snapshotCount === 1 - ? "This chat has a public link" - : `This chat has ${snapshotCount} public links`} - + Manage public links )} @@ -167,7 +166,7 @@ export function ChatShareButton({ thread, onVisibilityChange, className }: ChatS
- -
- {/* Content */} -
-
- {/* Auto mode */} + {/* Scrollable content */} +
{isAutoMode && ( - <> - - - - Auto mode distributes image generation requests across all configured - providers for optimal performance and rate limit protection. - - -
- - -
- + + + Auto mode distributes image generation requests across all configured + providers for optimal performance and rate limit protection. + + )} - {/* Global config (read-only) */} {isGlobal && !isAutoMode && config && ( <> - + Global configurations are read-only. To customize, create a new model. @@ -372,29 +346,11 @@ export function ImageConfigSidebar({
-
- - -
)} - {/* Create / Edit form */} {(mode === "create" || (mode === "edit" && !isGlobal)) && (
- {/* Name */}
- {/* Description */}
- {/* Provider */}
- {/* Model Name */}
{suggestedModels.length > 0 ? ( @@ -452,14 +402,17 @@ export function ImageConfigSidebar({ - - + + - {/* API Key */}
- +
- {/* API Base */}
- {/* Azure API Version */} {formData.provider === "AZURE_OPENAI" && (
@@ -549,28 +497,56 @@ export function ImageConfigSidebar({ />
)} - - {/* Actions */} -
- - -
)}
+ + {/* Fixed footer */} +
+ + {mode === "create" || (mode === "edit" && !isGlobal) ? ( + + ) : isAutoMode ? ( + + ) : isGlobal && config ? ( + + ) : null} +
@@ -578,5 +554,5 @@ export function ImageConfigSidebar({ ); - return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null; + return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null; } diff --git a/surfsense_web/components/new-chat/model-config-sidebar.tsx b/surfsense_web/components/new-chat/model-config-dialog.tsx similarity index 68% rename from surfsense_web/components/new-chat/model-config-sidebar.tsx rename to surfsense_web/components/new-chat/model-config-dialog.tsx index 90fb95c88..06ec3b9b5 100644 --- a/surfsense_web/components/new-chat/model-config-sidebar.tsx +++ b/surfsense_web/components/new-chat/model-config-dialog.tsx @@ -1,9 +1,9 @@ "use client"; import { useAtomValue } from "jotai"; -import { AlertCircle, Bot, ChevronRight, Globe, Shuffle, User, X, Zap } from "lucide-react"; +import { AlertCircle, X, Zap } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import { @@ -15,13 +15,15 @@ import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-c import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; import type { GlobalNewLLMConfig, + LiteLLMProvider, NewLLMConfigPublic, } from "@/contracts/types/new-llm-config.types"; import { cn } from "@/lib/utils"; -interface ModelConfigSidebarProps { +interface ModelConfigDialogProps { open: boolean; onOpenChange: (open: boolean) => void; config: NewLLMConfigPublic | GlobalNewLLMConfig | null; @@ -30,28 +32,34 @@ interface ModelConfigSidebarProps { mode: "create" | "edit" | "view"; } -export function ModelConfigSidebar({ +export function ModelConfigDialog({ open, onOpenChange, config, isGlobal, searchSpaceId, mode, -}: ModelConfigSidebarProps) { +}: ModelConfigDialogProps) { const [isSubmitting, setIsSubmitting] = useState(false); const [mounted, setMounted] = useState(false); + const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top"); + const scrollRef = useRef(null); - // Handle SSR - only render portal on client useEffect(() => { setMounted(true); }, []); - // Mutations - use mutateAsync from the atom value + const handleScroll = useCallback((e: React.UIEvent) => { + const el = e.currentTarget; + const atTop = el.scrollTop <= 2; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; + setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle"); + }, []); + const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom); const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom); const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom); - // Handle escape key useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && open) { @@ -62,10 +70,8 @@ export function ModelConfigSidebar({ return () => window.removeEventListener("keydown", handleEscape); }, [open, onOpenChange]); - // Check if this is Auto mode const isAutoMode = config && "is_auto_mode" in config && config.is_auto_mode; - // Get title based on mode const getTitle = () => { if (mode === "create") return "Add New Configuration"; if (isAutoMode) return "Auto Mode (Fastest)"; @@ -73,19 +79,23 @@ export function ModelConfigSidebar({ return "Edit Configuration"; }; - // Handle form submit + const getSubtitle = () => { + if (mode === "create") return "Set up a new LLM provider for this search space"; + if (isAutoMode) return "Automatically routes requests across providers"; + if (isGlobal) return "Read-only global configuration"; + return "Update your configuration settings"; + }; + const handleSubmit = useCallback( async (data: LLMConfigFormData) => { setIsSubmitting(true); try { if (mode === "create") { - // Create new config const result = await createConfig({ ...data, search_space_id: searchSpaceId, }); - // Assign the new config to the agent role if (result?.id) { await updatePreferences({ search_space_id: searchSpaceId, @@ -98,7 +108,6 @@ export function ModelConfigSidebar({ toast.success("Configuration created and assigned!"); onOpenChange(false); } else if (!isGlobal && config) { - // Update existing user config await updateConfig({ id: config.id, data: { @@ -137,7 +146,6 @@ export function ModelConfigSidebar({ ] ); - // Handle "Use this model" for global configs const handleUseGlobalConfig = useCallback(async () => { if (!config || !isGlobal) return; setIsSubmitting(true); @@ -160,7 +168,7 @@ export function ModelConfigSidebar({ if (!mounted) return null; - const sidebarContent = ( + const dialogContent = ( {open && ( <> @@ -169,93 +177,84 @@ export function ModelConfigSidebar({ initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} - transition={{ duration: 0.2 }} - className="fixed inset-0 z-[24] bg-black/20 backdrop-blur-sm" + transition={{ duration: 0.15 }} + className="fixed inset-0 z-[24] bg-black/50 backdrop-blur-sm" onClick={() => onOpenChange(false)} /> - {/* Sidebar Panel */} + {/* Dialog */} - {/* Header */}
e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === "Escape") onOpenChange(false); + }} > -
-
- {isAutoMode ? ( - - ) : ( - - )} -
-
-

{getTitle()}

-
- {isAutoMode ? ( - - + {/* Header */} +
+
+
+

{getTitle()}

+ {isAutoMode && ( + Recommended - ) : isGlobal ? ( - - + )} + {isGlobal && !isAutoMode && mode !== "create" && ( + Global - ) : mode !== "create" ? ( - - + )} + {!isGlobal && mode !== "create" && !isAutoMode && ( + Custom - ) : null} - {config && !isAutoMode && ( - {config.model_name} )}
+

{getSubtitle()}

+ {config && !isAutoMode && mode !== "create" && ( +

+ {config.model_name} +

+ )}
+
- -
- {/* Content - use overflow-y-auto instead of ScrollArea for better compatibility */} -
-
- {/* Auto mode info banner */} + {/* Scrollable content */} +
{isAutoMode && ( - - + Auto mode automatically distributes requests across all available LLM providers to optimize performance and avoid rate limits. @@ -263,9 +262,8 @@ export function ModelConfigSidebar({ )} - {/* Global config notice */} {isGlobal && !isAutoMode && mode !== "create" && ( - + Global configurations are read-only. To customize settings, create a new @@ -274,20 +272,17 @@ export function ModelConfigSidebar({ )} - {/* Form */} {mode === "create" ? ( onOpenChange(false)} isSubmitting={isSubmitting} mode="create" - submitLabel="Create & Use" + formId="model-config-form" + hideActions /> ) : isAutoMode && config ? ( - // Special view for Auto mode
- {/* Auto Mode Features */}
@@ -339,36 +334,9 @@ export function ModelConfigSidebar({
- - {/* Action Buttons */} -
- - -
) : isGlobal && config ? ( - // Read-only view for global configs
- {/* Config Details */}
@@ -436,43 +404,17 @@ export function ModelConfigSidebar({ )}
- - {/* Action Buttons */} -
- - -
) : config ? ( - // Edit form for user configs onOpenChange(false)} isSubmitting={isSubmitting} mode="edit" - submitLabel="Save Changes" + formId="model-config-form" + hideActions /> ) : null}
+ + {/* Fixed footer */} +
+ + {mode === "create" || (!isGlobal && !isAutoMode && config) ? ( + + ) : isAutoMode ? ( + + ) : isGlobal && config ? ( + + ) : null} +
@@ -495,5 +485,5 @@ export function ModelConfigSidebar({ ); - return typeof document !== "undefined" ? createPortal(sidebarContent, document.body) : null; + return typeof document !== "undefined" ? createPortal(dialogContent, document.body) : null; } diff --git a/surfsense_web/components/new-chat/model-selector.tsx b/surfsense_web/components/new-chat/model-selector.tsx index ab8248dd9..f23c3480f 100644 --- a/surfsense_web/components/new-chat/model-selector.tsx +++ b/surfsense_web/components/new-chat/model-selector.tsx @@ -2,7 +2,7 @@ import { useAtomValue } from "jotai"; import { Bot, Check, ChevronDown, Edit3, ImageIcon, Plus, Zap } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { type UIEvent, useCallback, useMemo, useState } from "react"; import { toast } from "sonner"; import { globalImageGenConfigsAtom, @@ -57,6 +57,17 @@ export function ModelSelector({ const [activeTab, setActiveTab] = useState<"llm" | "image">("llm"); const [llmSearchQuery, setLlmSearchQuery] = useState(""); const [imageSearchQuery, setImageSearchQuery] = useState(""); + const [llmScrollPos, setLlmScrollPos] = useState<"top" | "middle" | "bottom">("top"); + const [imageScrollPos, setImageScrollPos] = useState<"top" | "middle" | "bottom">("top"); + const handleListScroll = useCallback( + (setter: typeof setLlmScrollPos) => (e: UIEvent) => { + const el = e.currentTarget; + const atTop = el.scrollTop <= 2; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2; + setter(atTop ? "top" : atBottom ? "bottom" : "middle"); + }, + [] + ); // LLM data const { data: llmUserConfigs, isLoading: llmUserLoading } = useAtomValue(newLLMConfigsAtom); @@ -253,7 +264,7 @@ export function ModelSelector({ )} {/* Divider */} -
+
{/* Image section */} {currentImageConfig ? ( @@ -280,7 +291,7 @@ export function ModelSelector({ @@ -289,18 +300,18 @@ export function ModelSelector({ onValueChange={(v) => setActiveTab(v as "llm" | "image")} className="w-full" > -
+
LLM Image @@ -312,7 +323,7 @@ export function ModelSelector({ {totalLLMModels > 3 && (
@@ -325,7 +336,14 @@ export function ModelSelector({
)} - +
@@ -350,8 +368,8 @@ export function ModelSelector({ onSelect={() => handleSelectLLM(config)} className={cn( "mx-2 rounded-lg mb-1 cursor-pointer group transition-all", - "hover:bg-accent/50 dark:hover:bg-white/10", - isSelected && "bg-accent/80 dark:bg-white/10", + "hover:bg-accent/50 dark:hover:bg-white/[0.06]", + isSelected && "bg-accent/80 dark:bg-white/[0.06]", isAutoMode && "" )} > @@ -426,8 +444,8 @@ export function ModelSelector({ onSelect={() => handleSelectLLM(config)} className={cn( "mx-2 rounded-lg mb-1 cursor-pointer group transition-all", - "hover:bg-accent/50 dark:hover:bg-white/10", - isSelected && "bg-accent/80 dark:bg-white/10" + "hover:bg-accent/50 dark:hover:bg-white/[0.06]", + isSelected && "bg-accent/80 dark:bg-white/[0.06]" )} >
@@ -471,11 +489,11 @@ export function ModelSelector({ )} {/* Add New LLM Config */} -
+
- )} - + )} + +
+ )} ); diff --git a/surfsense_web/components/sources/DocumentUploadTab.tsx b/surfsense_web/components/sources/DocumentUploadTab.tsx index cae78f7b7..30db47801 100644 --- a/surfsense_web/components/sources/DocumentUploadTab.tsx +++ b/surfsense_web/components/sources/DocumentUploadTab.tsx @@ -2,7 +2,7 @@ import { useAtom } from "jotai"; import { CheckCircle2, FileType, Info, Upload, X } from "lucide-react"; -import { AnimatePresence, motion } from "motion/react"; + import { useTranslations } from "next-intl"; import { useCallback, useMemo, useRef, useState } from "react"; import { useDropzone } from "react-dropzone"; @@ -109,6 +109,11 @@ const FILE_TYPE_CONFIG: Record> = { }, }; +interface FileWithId { + id: string; + file: File; +} + const cardClass = "border border-border bg-slate-400/5 dark:bg-white/5"; // Upload limits — files are sent in batches of 5 to avoid proxy timeouts @@ -122,7 +127,7 @@ export function DocumentUploadTab({ onAccordionStateChange, }: DocumentUploadTabProps) { const t = useTranslations("upload_documents"); - const [files, setFiles] = useState([]); + const [files, setFiles] = useState([]); const [uploadProgress, setUploadProgress] = useState(0); const [accordionValue, setAccordionValue] = useState(""); const [shouldSummarize, setShouldSummarize] = useState(false); @@ -143,9 +148,12 @@ export function DocumentUploadTab({ const onDrop = useCallback( (acceptedFiles: File[]) => { setFiles((prev) => { - const newFiles = [...prev, ...acceptedFiles]; + const newEntries = acceptedFiles.map((f) => ({ + id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`, + file: f, + })); + const newFiles = [...prev, ...newEntries]; - // Check file count limit if (newFiles.length > MAX_FILES) { toast.error(t("max_files_exceeded"), { description: t("max_files_exceeded_desc", { max: MAX_FILES }), @@ -153,8 +161,7 @@ export function DocumentUploadTab({ return prev; } - // Check total size limit - const newTotalSize = newFiles.reduce((sum, file) => sum + file.size, 0); + const newTotalSize = newFiles.reduce((sum, entry) => sum + entry.file.size, 0); if (newTotalSize > MAX_TOTAL_SIZE_BYTES) { toast.error(t("max_size_exceeded"), { description: t("max_size_exceeded_desc", { max: MAX_TOTAL_SIZE_MB }), @@ -189,7 +196,7 @@ export function DocumentUploadTab({ return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; }; - const totalFileSize = files.reduce((total, file) => total + file.size, 0); + const totalFileSize = files.reduce((total, entry) => total + entry.file.size, 0); // Check if limits are reached const isFileCountLimitReached = files.length >= MAX_FILES; @@ -217,8 +224,13 @@ export function DocumentUploadTab({ setUploadProgress((prev) => (prev >= 90 ? prev : prev + Math.random() * 10)); }, 200); + const rawFiles = files.map((entry) => entry.file); uploadDocuments( - { files, search_space_id: Number(searchSpaceId), should_summarize: shouldSummarize }, + { + files: rawFiles, + search_space_id: Number(searchSpaceId), + should_summarize: shouldSummarize, + }, { onSuccess: () => { clearInterval(progressInterval); @@ -241,12 +253,7 @@ export function DocumentUploadTab({ }; return ( - +
@@ -287,14 +294,10 @@ export function DocumentUploadTab({
) : isDragActive ? ( - +

{t("drop_files")}

- +
) : (
@@ -312,7 +315,7 @@ export function DocumentUploadTab({ {!isFileCountLimitReached && (
+
+ + +
+ {files.map((entry) => ( +
+
+ +
+

{entry.file.name}

+
+ + {formatFileSize(entry.file.size)} + + + {entry.file.type || "Unknown type"} + +
+
- - -
- - {files.map((file, index) => ( - -
- -
-

{file.name}

-
- - {formatFileSize(file.size)} - - - {file.type || "Unknown type"} - -
-
-
- -
- ))} -
-
+ ))} +
- {isUploading && ( - - -
-
- {t("uploading_files")} - {Math.round(uploadProgress)}% -
- -
-
+ {isUploading && ( +
+ +
+
+ {t("uploading_files")} + {Math.round(uploadProgress)}% +
+ +
+
+ )} + +
+ +
+ +
+ - - - - - )} - + +
+
+ + )} - +
); } diff --git a/surfsense_web/components/tool-ui/google-drive/create-file.tsx b/surfsense_web/components/tool-ui/google-drive/create-file.tsx index f2cc97dcf..fb3fb65a0 100644 --- a/surfsense_web/components/tool-ui/google-drive/create-file.tsx +++ b/surfsense_web/components/tool-ui/google-drive/create-file.tsx @@ -6,7 +6,7 @@ import { CheckIcon, FileIcon, Loader2Icon, - PencilIcon, + Pen, RefreshCwIcon, XIcon, } from "lucide-react"; @@ -400,7 +400,7 @@ function ApprovalCard({ )} {canEdit && ( )} diff --git a/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx index b6eea7a28..ec45ff2aa 100644 --- a/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx @@ -1,7 +1,7 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; -import { AlertTriangleIcon, CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react"; +import { AlertTriangleIcon, CheckIcon, Loader2Icon, Pen, XIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -495,7 +495,7 @@ function ApprovalCard({ )} {canEdit && ( )} diff --git a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx index fa1c2c687..8a68a1176 100644 --- a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx @@ -1,14 +1,7 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; -import { - AlertTriangleIcon, - CheckIcon, - InfoIcon, - Loader2Icon, - PencilIcon, - XIcon, -} from "lucide-react"; +import { AlertTriangleIcon, CheckIcon, InfoIcon, Loader2Icon, Pen, XIcon } from "lucide-react"; import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -618,7 +611,7 @@ function ApprovalCard({ )} {canEdit && ( )} diff --git a/surfsense_web/components/tool-ui/notion/create-notion-page.tsx b/surfsense_web/components/tool-ui/notion/create-notion-page.tsx index e148c71ba..46e092649 100644 --- a/surfsense_web/components/tool-ui/notion/create-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/create-notion-page.tsx @@ -1,7 +1,7 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; -import { AlertTriangleIcon, CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react"; +import { AlertTriangleIcon, CheckIcon, Loader2Icon, Pen, XIcon } from "lucide-react"; import { useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -373,7 +373,7 @@ function ApprovalCard({ )} {canEdit && ( )} diff --git a/surfsense_web/components/tool-ui/notion/update-notion-page.tsx b/surfsense_web/components/tool-ui/notion/update-notion-page.tsx index de8fdd359..885c4325f 100644 --- a/surfsense_web/components/tool-ui/notion/update-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/update-notion-page.tsx @@ -8,7 +8,7 @@ import { Loader2Icon, MaximizeIcon, MinimizeIcon, - PencilIcon, + Pen, XIcon, } from "lucide-react"; import { useMemo, useState } from "react"; @@ -336,7 +336,7 @@ function ApprovalCard({ )} {canEdit && ( )} diff --git a/surfsense_web/components/ui/alert-dialog.tsx b/surfsense_web/components/ui/alert-dialog.tsx index 4f2d70378..53fa986e6 100644 --- a/surfsense_web/components/ui/alert-dialog.tsx +++ b/surfsense_web/components/ui/alert-dialog.tsx @@ -27,7 +27,7 @@ function AlertDialogOverlay({ ) { return ( ); diff --git a/surfsense_web/components/ui/animated-tabs.tsx b/surfsense_web/components/ui/animated-tabs.tsx new file mode 100644 index 000000000..7d39ce1dd --- /dev/null +++ b/surfsense_web/components/ui/animated-tabs.tsx @@ -0,0 +1,551 @@ +"use client"; + +import React, { + createContext, + forwardRef, + type ReactNode, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; + +import { cn } from "@/lib/utils"; + +/* ─────────────────────────── + Context (replaces cloneElement) + ─────────────────────────── */ + +interface TabsContextValue { + activeValue: string; + onValueChange: (value: string) => void; +} + +const TabsContext = createContext(null); + +function useTabsContext() { + const ctx = useContext(TabsContext); + if (!ctx) { + throw new Error("AnimatedTabs compound components must be rendered inside "); + } + return ctx; +} + +/* ─────────────────────────── + Constants (hoisted out of render) + ─────────────────────────── */ + +const SIZE_CLASSES = { + sm: "h-[32px] text-sm", + md: "h-[40px] text-base", + lg: "h-[48px] text-lg", +} as const; + +const VARIANT_CLASSES = { + default: "", + pills: "rounded-full", + underlined: "", +} as const; + +const ACTIVE_INDICATOR_CLASSES = { + default: "h-[4px] bg-primary dark:bg-primary", + pills: "hidden", + underlined: "h-[4px] bg-primary dark:bg-primary", +} as const; + +const HOVER_INDICATOR_CLASSES = { + default: "bg-muted dark:bg-muted rounded-[6px]", + pills: "bg-muted dark:bg-muted rounded-full", + underlined: "bg-muted dark:bg-muted rounded-[6px]", +} as const; + +/* ─────────────────────────── + XScrollable (internal) + ─────────────────────────── */ + +const XScrollable = forwardRef< + HTMLDivElement, + { + className?: string; + children?: ReactNode; + showScrollbar?: boolean; + contentClassName?: string; + } & React.HTMLAttributes +>(({ className, children, showScrollbar = true, contentClassName, ...props }, ref) => { + const scrollRef = useRef(null); + const dragging = useRef(false); + const startX = useRef(0); + const startScrollLeft = useRef(0); + const [scrollPos, setScrollPos] = useState<"start" | "middle" | "end">("start"); + + const updateScrollPos = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const canScroll = el.scrollWidth > el.clientWidth + 1; + if (!canScroll) { + setScrollPos("start"); + return; + } + const atStart = el.scrollLeft <= 2; + const atEnd = el.scrollWidth - el.scrollLeft - el.clientWidth <= 2; + setScrollPos(atStart ? "start" : atEnd ? "end" : "middle"); + }, []); + + useEffect(() => { + updateScrollPos(); + const el = scrollRef.current; + if (!el) return; + const ro = new ResizeObserver(updateScrollPos); + ro.observe(el); + return () => ro.disconnect(); + }, [updateScrollPos]); + + const onMouseDown = (e: React.MouseEvent) => { + if (!scrollRef.current) return; + dragging.current = true; + startX.current = e.clientX; + startScrollLeft.current = scrollRef.current.scrollLeft; + }; + const endDrag = () => { + dragging.current = false; + }; + const onMouseMove = (e: React.MouseEvent) => { + if (!dragging.current || !scrollRef.current) return; + e.preventDefault(); + const dx = e.clientX - startX.current; + scrollRef.current.scrollLeft = startScrollLeft.current - dx; + }; + + const onWheel = (e: React.WheelEvent) => { + if (!scrollRef.current) return; + const delta = Math.abs(e.deltaY) > Math.abs(e.deltaX) ? e.deltaY : e.deltaX; + if (delta !== 0) { + e.preventDefault(); + scrollRef.current.scrollLeft += delta; + } + }; + + const handleScroll = useCallback(() => { + updateScrollPos(); + }, [updateScrollPos]); + + const maskStart = scrollPos === "start" ? "black" : "transparent"; + const maskEnd = scrollPos === "end" ? "black" : "transparent"; + const maskImage = `linear-gradient(to right, ${maskStart}, black 24px, black calc(100% - 24px), ${maskEnd})`; + + return ( + // biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll container needs mouse events +
+ {/* biome-ignore lint/a11y/noStaticElementInteractions: drag-scroll requires onMouseDown */} +
+ {children} +
+
+ ); +}); +XScrollable.displayName = "XScrollable"; + +/* ─────────────────────────── + Tabs (root) + ─────────────────────────── */ + +const Tabs = forwardRef< + HTMLDivElement, + { + defaultValue?: string; + value?: string; + onValueChange?: (value: string) => void; + className?: string; + children?: ReactNode; + } +>(({ defaultValue, value, onValueChange, className, children, ...props }, ref) => { + const [activeValue, setActiveValue] = useState(value || defaultValue || ""); + + useEffect(() => { + if (value !== undefined) { + setActiveValue(value); + } + }, [value]); + + const handleValueChange = useCallback( + (newValue: string) => { + if (value === undefined) { + setActiveValue(newValue); + } + onValueChange?.(newValue); + }, + [onValueChange, value] + ); + + return ( + +
+ {children} +
+
+ ); +}); +Tabs.displayName = "Tabs"; + +/* ─────────────────────────── + TabsList + ─────────────────────────── */ + +type TabsListVariant = "default" | "pills" | "underlined"; +type TabsListSize = "sm" | "md" | "lg"; + +const TabsList = forwardRef< + HTMLDivElement, + { + className?: string; + children?: ReactNode; + showHoverEffect?: boolean; + showActiveIndicator?: boolean; + activeIndicatorPosition?: "top" | "bottom"; + activeIndicatorOffset?: number; + size?: TabsListSize; + variant?: TabsListVariant; + stretch?: boolean; + ariaLabel?: string; + showBottomBorder?: boolean; + bottomBorderClassName?: string; + activeIndicatorClassName?: string; + hoverIndicatorClassName?: string; + } +>( + ( + { + className, + children, + showHoverEffect = true, + showActiveIndicator = true, + activeIndicatorPosition = "bottom", + activeIndicatorOffset = 0, + size = "sm", + variant = "default", + stretch = false, + ariaLabel = "Tabs", + showBottomBorder = false, + bottomBorderClassName, + activeIndicatorClassName, + hoverIndicatorClassName, + ...props + }, + ref + ) => { + const { activeValue, onValueChange } = useTabsContext(); + + const [hoveredIndex, setHoveredIndex] = useState(null); + const [hoverStyle, setHoverStyle] = useState({}); + const [activeStyle, setActiveStyle] = useState({ + left: "0px", + width: "0px", + }); + const tabRefs = useRef<(HTMLDivElement | null)[]>([]); + const scrollContainerRef = useRef(null); + + const activeIndex = React.Children.toArray(children).findIndex( + (child) => + React.isValidElement(child) && + (child as React.ReactElement<{ value: string }>).props.value === activeValue + ); + + useEffect(() => { + if (hoveredIndex !== null && showHoverEffect) { + const hoveredElement = tabRefs.current[hoveredIndex]; + if (hoveredElement) { + const { offsetLeft, offsetWidth } = hoveredElement; + setHoverStyle({ + left: `${offsetLeft}px`, + width: `${offsetWidth}px`, + }); + } + } + }, [hoveredIndex, showHoverEffect]); + + const updateActiveIndicator = useCallback(() => { + if (showActiveIndicator && activeIndex >= 0) { + const activeElement = tabRefs.current[activeIndex]; + if (activeElement) { + const { offsetLeft, offsetWidth } = activeElement; + setActiveStyle({ + left: `${offsetLeft}px`, + width: `${offsetWidth}px`, + }); + } + } + }, [showActiveIndicator, activeIndex]); + + useEffect(() => { + updateActiveIndicator(); + }, [updateActiveIndicator]); + + useEffect(() => { + requestAnimationFrame(updateActiveIndicator); + }, [updateActiveIndicator]); + + const scrollTabToCenter = useCallback((index: number) => { + const tabElement = tabRefs.current[index]; + const scrollContainer = scrollContainerRef.current; + + if (tabElement && scrollContainer) { + const containerWidth = scrollContainer.offsetWidth; + const tabWidth = tabElement.offsetWidth; + const tabLeft = tabElement.offsetLeft; + const scrollTarget = tabLeft - containerWidth / 2 + tabWidth / 2; + scrollContainer.scrollTo({ left: scrollTarget, behavior: "smooth" }); + } + }, []); + + const setTabRef = useCallback((el: HTMLDivElement | null, index: number) => { + tabRefs.current[index] = el; + }, []); + + const handleScrollableRef = useCallback((node: HTMLDivElement | null) => { + if (node) { + const scrollableDiv = node.querySelector('div[class*="overflow-x-auto"]'); + if (scrollableDiv) { + scrollContainerRef.current = scrollableDiv as HTMLDivElement; + } + } + }, []); + + useEffect(() => { + if (activeIndex >= 0) { + const timer = setTimeout(() => { + scrollTabToCenter(activeIndex); + }, 100); + return () => clearTimeout(timer); + } + }, [activeIndex, scrollTabToCenter]); + + return ( +
+ {showBottomBorder && ( +
+ )} + +
+ {showHoverEffect && ( + + ); + } +); +TabsList.displayName = "TabsList"; + +/* ─────────────────────────── + TabsTrigger + ─────────────────────────── */ + +const TabsTrigger = forwardRef< + HTMLDivElement, + { + value: string; + disabled?: boolean; + label?: string; + className?: string; + activeClassName?: string; + inactiveClassName?: string; + disabledClassName?: string; + children?: ReactNode; + } +>( + ( + { + value, + disabled = false, + label, + className, + activeClassName, + inactiveClassName, + disabledClassName, + children, + ...props + }, + ref + ) => { + return ( +
+ {label || children} +
+ ); + } +); +TabsTrigger.displayName = "TabsTrigger"; + +/* ─────────────────────────── + TabsContent + ─────────────────────────── */ + +const TabsContent = forwardRef< + HTMLDivElement, + { + value: string; + className?: string; + children: ReactNode; + } +>(({ value, className, children, ...props }, ref) => { + const { activeValue } = useTabsContext(); + + if (value !== activeValue) return null; + return ( +
+ {children} +
+ ); +}); +TabsContent.displayName = "TabsContent"; + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/surfsense_web/components/ui/breadcrumb.tsx b/surfsense_web/components/ui/breadcrumb.tsx deleted file mode 100644 index e2451573f..000000000 --- a/surfsense_web/components/ui/breadcrumb.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { Slot } from "@radix-ui/react-slot"; -import { ChevronRight, MoreHorizontal } from "lucide-react"; - -import { cn } from "@/lib/utils"; - -function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { - return