- We are always looking for ways to improve our products and services. Contact us and let us
- know how we can help you.
+ We'd love to Hear From You.
From 79429b0c9a34fe36cbb36357e82134e2224897ce Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Tue, 30 Sep 2025 22:26:32 -0700
Subject: [PATCH 05/25] remove framer-motion
---
surfsense_web/components/pricing.tsx | 2 +-
surfsense_web/package.json | 1 -
surfsense_web/pnpm-lock.yaml | 3 ---
3 files changed, 1 insertion(+), 5 deletions(-)
diff --git a/surfsense_web/components/pricing.tsx b/surfsense_web/components/pricing.tsx
index 3d19e1ea9..b666c8982 100644
--- a/surfsense_web/components/pricing.tsx
+++ b/surfsense_web/components/pricing.tsx
@@ -2,7 +2,7 @@
import NumberFlow from "@number-flow/react";
import confetti from "canvas-confetti";
-import { motion } from "framer-motion";
+import { motion } from "motion/react";
import { Check, Star } from "lucide-react";
import Link from "next/link";
import { useRef, useState } from "react";
diff --git a/surfsense_web/package.json b/surfsense_web/package.json
index b17dfd626..517e7690d 100644
--- a/surfsense_web/package.json
+++ b/surfsense_web/package.json
@@ -54,7 +54,6 @@
"dotenv": "^17.2.3",
"drizzle-orm": "^0.44.5",
"emblor": "^1.4.8",
- "framer-motion": "^12.23.22",
"fumadocs-core": "^15.6.6",
"fumadocs-mdx": "^11.7.1",
"fumadocs-ui": "^15.6.6",
diff --git a/surfsense_web/pnpm-lock.yaml b/surfsense_web/pnpm-lock.yaml
index a62cd0a53..bfda2d29d 100644
--- a/surfsense_web/pnpm-lock.yaml
+++ b/surfsense_web/pnpm-lock.yaml
@@ -113,9 +113,6 @@ importers:
emblor:
specifier: ^1.4.8
version: 1.4.8(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
- framer-motion:
- specifier: ^12.23.22
- version: 12.23.22(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
fumadocs-core:
specifier: ^15.6.6
version: 15.6.6(@types/react@19.1.8)(next@15.4.4(@opentelemetry/api@1.9.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
From e274c1307c798b49adb9bf1f9731e82ef65cb525 Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Tue, 30 Sep 2025 22:45:04 -0700
Subject: [PATCH 06/25] try vercel fix
---
surfsense_web/app/db/index.ts | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/surfsense_web/app/db/index.ts b/surfsense_web/app/db/index.ts
index 277848bb5..db86725e1 100644
--- a/surfsense_web/app/db/index.ts
+++ b/surfsense_web/app/db/index.ts
@@ -2,5 +2,12 @@ import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
-const client = postgres(process.env.DATABASE_URL!);
+// Configure postgres client for Vercel serverless environment
+const client = postgres(process.env.DATABASE_URL!, {
+ max: 1, // Limit connections for serverless (Vercel)
+ idle_timeout: 20, // Close idle connections after 20 seconds
+ max_lifetime: 60 * 30, // Close connections after 30 minutes
+ connect_timeout: 10, // Connection timeout in seconds
+});
+
export const db = drizzle({ client, schema });
From 63b2c2f0a097117cca7c5ef776291dbb4c03f31c Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Tue, 30 Sep 2025 23:05:12 -0700
Subject: [PATCH 07/25] fix: sitemap
---
surfsense_web/app/sitemap.ts | 20 +++++++++++++-------
1 file changed, 13 insertions(+), 7 deletions(-)
diff --git a/surfsense_web/app/sitemap.ts b/surfsense_web/app/sitemap.ts
index 1380fc561..70fb45491 100644
--- a/surfsense_web/app/sitemap.ts
+++ b/surfsense_web/app/sitemap.ts
@@ -3,43 +3,49 @@ import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
- url: "https://www.surfsense.net/",
+ url: "https://www.surfsense.com/",
lastModified: new Date(),
changeFrequency: "yearly",
priority: 1,
},
{
- url: "https://www.surfsense.net/privacy",
+ url: "https://www.surfsense.com/contact",
+ lastModified: new Date(),
+ changeFrequency: "yearly",
+ priority: 1,
+ },
+ {
+ url: "https://www.surfsense.com/privacy",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.9,
},
{
- url: "https://www.surfsense.net/terms",
+ url: "https://www.surfsense.com/terms",
lastModified: new Date(),
changeFrequency: "monthly",
priority: 0.9,
},
{
- url: "https://www.surfsense.net/docs",
+ url: "https://www.surfsense.com/docs",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
{
- url: "https://www.surfsense.net/docs/installation",
+ url: "https://www.surfsense.com/docs/installation",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
{
- url: "https://www.surfsense.net/docs/docker-installation",
+ url: "https://www.surfsense.com/docs/docker-installation",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
},
{
- url: "https://www.surfsense.net/docs/manual-installation",
+ url: "https://www.surfsense.com/docs/manual-installation",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 0.9,
From 797fe26f5357ea2247e7326fc6e921c47dc21a66 Mon Sep 17 00:00:00 2001
From: Natsume Ryuhane
Date: Wed, 1 Oct 2025 13:05:22 -0700
Subject: [PATCH 08/25] Implemented serverside pagination; Enabled searchspace
file mgmt panel to use serverside pagination;
---
.../app/routes/documents_routes.py | 151 ++++++++++++++++-
surfsense_backend/app/schemas/__init__.py | 2 +
surfsense_backend/app/schemas/documents.py | 8 +
.../documents/(manage)/page.tsx | 83 ++++++----
.../components/chat/ChatInputGroup.tsx | 5 +-
surfsense_web/hooks/use-documents.ts | 153 ++++++++++++++----
surfsense_web/lib/pagination.ts | 34 ++++
7 files changed, 374 insertions(+), 62 deletions(-)
create mode 100644 surfsense_web/lib/pagination.ts
diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py
index b75c56663..dd7b56033 100644
--- a/surfsense_backend/app/routes/documents_routes.py
+++ b/surfsense_backend/app/routes/documents_routes.py
@@ -22,6 +22,7 @@ from app.schemas import (
DocumentsCreate,
DocumentUpdate,
DocumentWithChunksRead,
+ PaginatedResponse,
)
from app.services.task_logging_service import TaskLoggingService
from app.tasks.document_processors import (
@@ -154,15 +155,36 @@ async def create_documents_file_upload(
) from e
-@router.get("/documents/", response_model=list[DocumentRead])
+@router.get("/documents/", response_model=PaginatedResponse[DocumentRead])
async def read_documents(
- skip: int = 0,
- limit: int = 3000,
+ skip: int | None = None,
+ page: int | None = None,
+ page_size: int = 50,
search_space_id: int | None = None,
session: AsyncSession = Depends(get_async_session),
user: User = Depends(current_active_user),
):
+ """
+ List documents owned by the current user, with optional filtering and pagination.
+
+ Args:
+ skip: Absolute number of items to skip from the beginning. If provided, it takes precedence over 'page'.
+ page: Zero-based page index used when 'skip' is not provided.
+ page_size: Number of items per page (default: 50). Use -1 to return all remaining items after the offset.
+ search_space_id: If provided, restrict results to a specific search space.
+ session: Database session (injected).
+ user: Current authenticated user (injected).
+
+ Returns:
+ PaginatedResponse[DocumentRead]: Paginated list of documents visible to the user.
+
+ Notes:
+ - If both 'skip' and 'page' are provided, 'skip' is used.
+ - Results are scoped to documents owned by the current user.
+ """
try:
+ from sqlalchemy import func
+
query = (
select(Document).join(SearchSpace).filter(SearchSpace.user_id == user.id)
)
@@ -171,7 +193,33 @@ async def read_documents(
if search_space_id is not None:
query = query.filter(Document.search_space_id == search_space_id)
- result = await session.execute(query.offset(skip).limit(limit))
+ # Get total count
+ count_query = (
+ select(func.count())
+ .select_from(Document)
+ .join(SearchSpace)
+ .filter(SearchSpace.user_id == user.id)
+ )
+ if search_space_id is not None:
+ count_query = count_query.filter(
+ Document.search_space_id == search_space_id
+ )
+ total_result = await session.execute(count_query)
+ total = total_result.scalar() or 0
+
+ # Calculate offset
+ offset = 0
+ if skip is not None:
+ offset = skip
+ elif page is not None:
+ offset = page * page_size
+
+ # Get paginated results
+ if page_size == -1:
+ result = await session.execute(query.offset(offset))
+ else:
+ result = await session.execute(query.offset(offset).limit(page_size))
+
db_documents = result.scalars().all()
# Convert database objects to API-friendly format
@@ -189,13 +237,106 @@ async def read_documents(
)
)
- return api_documents
+ return PaginatedResponse(items=api_documents, total=total)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to fetch documents: {e!s}"
) from e
+@router.get("/documents/search/", response_model=PaginatedResponse[DocumentRead])
+async def search_documents(
+ title: str,
+ skip: int | None = None,
+ page: int | None = None,
+ page_size: int = 50,
+ search_space_id: int | None = None,
+ session: AsyncSession = Depends(get_async_session),
+ user: User = Depends(current_active_user),
+):
+ """
+ Search documents by title substring, optionally filtered by search_space_id.
+
+ Args:
+ title: Case-insensitive substring to match against document titles. Required.
+ skip: Absolute number of items to skip from the beginning. If provided, it takes precedence over 'page'. Default: None.
+ page: Zero-based page index used when 'skip' is not provided. Default: None.
+ page_size: Number of items per page. Use -1 to return all remaining items after the offset. Default: 50.
+ search_space_id: Filter results to a specific search space. Default: None.
+ session: Database session (injected).
+ user: Current authenticated user (injected).
+
+ Returns:
+ PaginatedResponse[DocumentRead]: Paginated list of documents matching the query and filter.
+
+ Notes:
+ - Title matching uses ILIKE (case-insensitive).
+ - If both 'skip' and 'page' are provided, 'skip' is used.
+ """
+ try:
+ from sqlalchemy import func
+
+ query = (
+ select(Document).join(SearchSpace).filter(SearchSpace.user_id == user.id)
+ )
+ if search_space_id is not None:
+ query = query.filter(Document.search_space_id == search_space_id)
+
+ # Only search by title (case-insensitive)
+ query = query.filter(Document.title.ilike(f"%{title}%"))
+
+ # Get total count
+ count_query = (
+ select(func.count())
+ .select_from(Document)
+ .join(SearchSpace)
+ .filter(SearchSpace.user_id == user.id)
+ )
+ if search_space_id is not None:
+ count_query = count_query.filter(
+ Document.search_space_id == search_space_id
+ )
+ count_query = count_query.filter(Document.title.ilike(f"%{title}%"))
+ total_result = await session.execute(count_query)
+ total = total_result.scalar() or 0
+
+ # Calculate offset
+ offset = 0
+ if skip is not None:
+ offset = skip
+ elif page is not None:
+ offset = page * page_size
+
+ # Get paginated results
+ if page_size == -1:
+ result = await session.execute(query.offset(offset))
+ else:
+ result = await session.execute(query.offset(offset).limit(page_size))
+
+ db_documents = result.scalars().all()
+
+ # Convert database objects to API-friendly format
+ api_documents = []
+ for doc in db_documents:
+ api_documents.append(
+ DocumentRead(
+ id=doc.id,
+ title=doc.title,
+ document_type=doc.document_type,
+ document_metadata=doc.document_metadata,
+ content=doc.content,
+ created_at=doc.created_at,
+ search_space_id=doc.search_space_id,
+ )
+ )
+
+ return PaginatedResponse(items=api_documents, total=total)
+ except Exception as e:
+ raise HTTPException(
+ status_code=500, detail=f"Failed to search documents: {e!s}"
+ ) from e
+
+
@router.get("/documents/{document_id}", response_model=DocumentRead)
async def read_document(
document_id: int,
diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py
index ca9287a16..41b2ce23c 100644
--- a/surfsense_backend/app/schemas/__init__.py
+++ b/surfsense_backend/app/schemas/__init__.py
@@ -16,6 +16,7 @@ from .documents import (
DocumentWithChunksRead,
ExtensionDocumentContent,
ExtensionDocumentMetadata,
+ PaginatedResponse,
)
from .llm_config import LLMConfigBase, LLMConfigCreate, LLMConfigRead, LLMConfigUpdate
from .logs import LogBase, LogCreate, LogFilter, LogRead, LogUpdate
@@ -68,6 +69,7 @@ __all__ = [
"LogFilter",
"LogRead",
"LogUpdate",
+ "PaginatedResponse",
"PodcastBase",
"PodcastCreate",
"PodcastGenerateRequest",
diff --git a/surfsense_backend/app/schemas/documents.py b/surfsense_backend/app/schemas/documents.py
index bdaf568e7..80f6dc0a7 100644
--- a/surfsense_backend/app/schemas/documents.py
+++ b/surfsense_backend/app/schemas/documents.py
@@ -1,11 +1,14 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict
+from typing import Generic, TypeVar
from app.db import DocumentType
from .chunks import ChunkRead
+T = TypeVar("T")
+
class ExtensionDocumentMetadata(BaseModel):
BrowsingSessionId: str
@@ -53,3 +56,8 @@ class DocumentWithChunksRead(DocumentRead):
chunks: list[ChunkRead] = []
model_config = ConfigDict(from_attributes=True)
+
+
+class PaginatedResponse(BaseModel, Generic[T]):
+ items: list[T]
+ total: int
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx
index 4a69a7533..67b55c105 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx
@@ -2,7 +2,7 @@
import { motion } from "framer-motion";
import { useParams } from "next/navigation";
-import { useEffect, useId, useMemo, useState } from "react";
+import { useCallback, useEffect, useId, useMemo, useState } from "react";
import { toast } from "sonner";
import { useDocuments } from "@/hooks/use-documents";
@@ -26,10 +26,6 @@ export default function DocumentsTable() {
const params = useParams();
const searchSpaceId = Number(params.search_space_id);
- const { documents, loading, error, refreshDocuments, deleteDocument } =
- useDocuments(searchSpaceId);
-
- const [data, setData] = useState([]);
const [search, setSearch] = useState("");
const debouncedSearch = useDebounced(search, 250);
const [activeTypes, setActiveTypes] = useState([]);
@@ -45,26 +41,48 @@ export default function DocumentsTable() {
const [sortDesc, setSortDesc] = useState(false);
const [selectedIds, setSelectedIds] = useState>(new Set());
- useEffect(() => {
- if (documents) setData(documents as Document[]);
- }, [documents]);
+ // Use server-side pagination and search
+ const {
+ documents,
+ total,
+ loading,
+ error,
+ fetchDocuments,
+ searchDocuments,
+ deleteDocument,
+ } = useDocuments(searchSpaceId, {
+ page: pageIndex,
+ pageSize: pageSize,
+ });
- const filtered = useMemo(() => {
- let result = data;
- if (debouncedSearch.trim()) {
- const q = debouncedSearch.toLowerCase();
- result = result.filter((d) => d.title.toLowerCase().includes(q));
+ // Refetch when pagination changes or when search/filters change
+ useEffect(() => {
+ if (searchSpaceId) {
+ if (debouncedSearch.trim()) {
+ // Use search endpoint if there's a search query
+ searchDocuments?.(debouncedSearch, pageIndex, pageSize);
+ } else {
+ // Use regular fetch if no search
+ fetchDocuments?.(pageIndex, pageSize);
+ }
}
+ }, [pageIndex, pageSize, debouncedSearch, searchSpaceId, fetchDocuments, searchDocuments]);
+
+ // Client-side filtering for document types only
+ // Note: This could also be moved to the backend for better performance
+ const filtered = useMemo(() => {
+ let result = documents || [];
if (activeTypes.length > 0) {
result = result.filter((d) => activeTypes.includes(d.document_type));
}
return result;
- }, [data, debouncedSearch, activeTypes]);
+ }, [documents, activeTypes]);
- const total = filtered.length;
+ // Display filtered results
+ const displayDocs = filtered;
+ const displayTotal = activeTypes.length > 0 ? filtered.length : total;
const pageStart = pageIndex * pageSize;
- const pageEnd = Math.min(pageStart + pageSize, total);
- const pageDocs = filtered.slice(pageStart, pageEnd);
+ const pageEnd = Math.min(pageStart + pageSize, displayTotal);
const onToggleType = (type: string, checked: boolean) => {
setActiveTypes((prev) => (checked ? [...prev, type] : prev.filter((t) => t !== type)));
@@ -75,6 +93,14 @@ export default function DocumentsTable() {
setColumnVisibility((prev) => ({ ...prev, [id]: checked }));
};
+ const refreshCurrentView = useCallback(async () => {
+ if (debouncedSearch.trim()) {
+ await searchDocuments?.(debouncedSearch, pageIndex, pageSize);
+ } else {
+ await fetchDocuments?.(pageIndex, pageSize);
+ }
+ }, [debouncedSearch, pageIndex, pageSize, searchDocuments, fetchDocuments]);
+
const onBulkDelete = async () => {
if (selectedIds.size === 0) {
toast.error("No rows selected");
@@ -86,7 +112,8 @@ export default function DocumentsTable() {
if (okCount === selectedIds.size)
toast.success(`Successfully deleted ${okCount} document(s)`);
else toast.error("Some documents could not be deleted");
- await refreshDocuments?.();
+ // Refetch the current page with appropriate method
+ await refreshCurrentView();
setSelectedIds(new Set());
} catch (e) {
console.error(e);
@@ -113,8 +140,8 @@ export default function DocumentsTable() {
className="w-full px-6 py-4"
>
{
- await (refreshDocuments?.() ?? Promise.resolve());
- }}
+ onRefresh={refreshCurrentView}
selectedIds={selectedIds}
setSelectedIds={setSelectedIds}
columnVisibility={columnVisibility}
@@ -147,20 +172,22 @@ export default function DocumentsTable() {
}}
/>
+
+export { DocumentsTable };
{
setPageSize(s);
setPageIndex(0);
}}
onFirst={() => setPageIndex(0)}
onPrev={() => setPageIndex((i) => Math.max(0, i - 1))}
- onNext={() => setPageIndex((i) => (pageEnd < total ? i + 1 : i))}
- onLast={() => setPageIndex(Math.max(0, Math.ceil(total / pageSize) - 1))}
+ onNext={() => setPageIndex((i) => (pageEnd < displayTotal ? i + 1 : i))}
+ onLast={() => setPageIndex(Math.max(0, Math.ceil(displayTotal / pageSize) - 1))}
canPrev={pageIndex > 0}
- canNext={pageEnd < total}
+ canNext={pageEnd < displayTotal}
id={id}
/>
diff --git a/surfsense_web/components/chat/ChatInputGroup.tsx b/surfsense_web/components/chat/ChatInputGroup.tsx
index 1f5cc6d31..8085af6eb 100644
--- a/surfsense_web/components/chat/ChatInputGroup.tsx
+++ b/surfsense_web/components/chat/ChatInputGroup.tsx
@@ -42,7 +42,10 @@ const DocumentSelector = React.memo(
const { documents, loading, isLoaded, fetchDocuments } = useDocuments(
Number(search_space_id),
- true
+ {
+ lazy: true,
+ pageSize: -1, // Fetch all documents with large page size
+ }
);
const handleOpenChange = useCallback(
diff --git a/surfsense_web/hooks/use-documents.ts b/surfsense_web/hooks/use-documents.ts
index 4c7536689..9dba04e93 100644
--- a/surfsense_web/hooks/use-documents.ts
+++ b/surfsense_web/hooks/use-documents.ts
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
+import { normalizeListResponse } from "@/lib/pagination";
export interface Document {
id: number;
@@ -29,43 +30,79 @@ export type DocumentType =
| "GOOGLE_GMAIL_CONNECTOR"
| "AIRTABLE_CONNECTOR";
-export function useDocuments(searchSpaceId: number, lazy: boolean = false) {
+export interface UseDocumentsOptions {
+ page?: number;
+ pageSize?: number;
+ lazy?: boolean;
+}
+
+export function useDocuments(
+ searchSpaceId: number,
+ options?: UseDocumentsOptions | boolean
+) {
+ // Support both old boolean API and new options API for backward compatibility
+ const opts = typeof options === "boolean" ? { lazy: options } : options || {};
+ const { page, pageSize = 300, lazy = false } = opts;
+
const [documents, setDocuments] = useState([]);
+ const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(!lazy); // Don't show loading initially for lazy mode
const [error, setError] = useState(null);
const [isLoaded, setIsLoaded] = useState(false); // Memoization flag
- const fetchDocuments = useCallback(async () => {
- if (isLoaded && lazy) return; // Avoid redundant calls in lazy mode
+ const fetchDocuments = useCallback(
+ async (fetchPage?: number, fetchPageSize?: number) => {
+ if (isLoaded && lazy) return; // Avoid redundant calls in lazy mode
- try {
- setLoading(true);
- const response = await fetch(
- `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents?search_space_id=${searchSpaceId}`,
- {
- headers: {
- Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
- },
- method: "GET",
+ try {
+ setLoading(true);
+
+ // Build query params
+ const params = new URLSearchParams({
+ search_space_id: searchSpaceId.toString(),
+ });
+
+ // Use passed parameters or fall back to state/options
+ const effectivePage = fetchPage !== undefined ? fetchPage : page;
+ const effectivePageSize = fetchPageSize !== undefined ? fetchPageSize : pageSize;
+
+ if (effectivePage !== undefined) {
+ params.append("page", effectivePage.toString());
+ }
+ if (effectivePageSize !== undefined) {
+ params.append("page_size", effectivePageSize.toString());
}
- );
- if (!response.ok) {
- toast.error("Failed to fetch documents");
- throw new Error("Failed to fetch documents");
+ const response = await fetch(
+ `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents?${params.toString()}`,
+ {
+ headers: {
+ Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
+ },
+ method: "GET",
+ }
+ );
+
+ if (!response.ok) {
+ toast.error("Failed to fetch documents");
+ throw new Error("Failed to fetch documents");
+ }
+
+ const data = await response.json();
+ const normalized = normalizeListResponse(data);
+ setDocuments(normalized.items);
+ setTotal(normalized.total);
+ setError(null);
+ setIsLoaded(true);
+ } catch (err: any) {
+ setError(err.message || "Failed to fetch documents");
+ console.error("Error fetching documents:", err);
+ } finally {
+ setLoading(false);
}
-
- const data = await response.json();
- setDocuments(data);
- setError(null);
- setIsLoaded(true);
- } catch (err: any) {
- setError(err.message || "Failed to fetch documents");
- console.error("Error fetching documents:", err);
- } finally {
- setLoading(false);
- }
- }, [searchSpaceId, isLoaded, lazy]);
+ },
+ [searchSpaceId, page, pageSize, isLoaded, lazy]
+ );
useEffect(() => {
if (!lazy && searchSpaceId) {
@@ -79,6 +116,64 @@ export function useDocuments(searchSpaceId: number, lazy: boolean = false) {
await fetchDocuments();
}, [fetchDocuments]);
+ // Function to search documents by title
+ const searchDocuments = useCallback(
+ async (searchQuery: string, fetchPage?: number, fetchPageSize?: number) => {
+ if (!searchQuery.trim()) {
+ // If search is empty, fetch all documents
+ return fetchDocuments(fetchPage, fetchPageSize);
+ }
+
+ try {
+ setLoading(true);
+
+ // Build query params
+ const params = new URLSearchParams({
+ search_space_id: searchSpaceId.toString(),
+ title: searchQuery,
+ });
+
+ // Use passed parameters or fall back to state/options
+ const effectivePage = fetchPage !== undefined ? fetchPage : page;
+ const effectivePageSize = fetchPageSize !== undefined ? fetchPageSize : pageSize;
+
+ if (effectivePage !== undefined) {
+ params.append("page", effectivePage.toString());
+ }
+ if (effectivePageSize !== undefined) {
+ params.append("page_size", effectivePageSize.toString());
+ }
+
+ const response = await fetch(
+ `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/search/?${params.toString()}`,
+ {
+ headers: {
+ Authorization: `Bearer ${localStorage.getItem("surfsense_bearer_token")}`,
+ },
+ method: "GET",
+ }
+ );
+
+ if (!response.ok) {
+ toast.error("Failed to search documents");
+ throw new Error("Failed to search documents");
+ }
+
+ const data = await response.json();
+ const normalized = normalizeListResponse(data);
+ setDocuments(normalized.items);
+ setTotal(normalized.total);
+ setError(null);
+ } catch (err: any) {
+ setError(err.message || "Failed to search documents");
+ console.error("Error searching documents:", err);
+ } finally {
+ setLoading(false);
+ }
+ },
+ [searchSpaceId, page, pageSize, fetchDocuments]
+ );
+
// Function to delete a document
const deleteDocument = useCallback(
async (documentId: number) => {
@@ -113,10 +208,12 @@ export function useDocuments(searchSpaceId: number, lazy: boolean = false) {
return {
documents,
+ total,
loading,
error,
isLoaded,
fetchDocuments, // Manual fetch function for lazy mode
+ searchDocuments, // Search function
refreshDocuments,
deleteDocument,
};
diff --git a/surfsense_web/lib/pagination.ts b/surfsense_web/lib/pagination.ts
new file mode 100644
index 000000000..0c618e56e
--- /dev/null
+++ b/surfsense_web/lib/pagination.ts
@@ -0,0 +1,34 @@
+// Helper to normalize list responses from the API
+// Supports shapes: Array, { items: T[]; total: number }, and tuple [T[], total]
+export type ListResponse = {
+ items: T[];
+ total: number;
+};
+
+export function normalizeListResponse(payload: any): ListResponse {
+ try {
+ // Case 1: already in desired shape
+ if (payload && Array.isArray(payload.items)) {
+ const total = typeof payload.total === "number" ? payload.total : payload.items.length;
+ return { items: payload.items as T[], total };
+ }
+
+ // Case 2: tuple [items, total]
+ if (Array.isArray(payload) && payload.length === 2 && Array.isArray(payload[0])) {
+ const items = (payload[0] ?? []) as T[];
+ const rawTotal = payload[1];
+ const total = typeof rawTotal === "number" ? rawTotal : items.length;
+ return { items, total };
+ }
+
+ // Case 3: plain array
+ if (Array.isArray(payload)) {
+ return { items: payload as T[], total: (payload as T[]).length };
+ }
+ } catch (e) {
+ // fallthrough to default
+ }
+
+ return { items: [], total: 0 };
+}
+
From 898fc884a91290371f065c2e3d328bbbe81f6b8e Mon Sep 17 00:00:00 2001
From: "MSI\\ModSetter"
Date: Wed, 1 Oct 2025 18:50:36 -0700
Subject: [PATCH 09/25] chore: biome & ruff checks
---
surfsense_backend/app/schemas/documents.py | 4 +-
.../documents/(manage)/page.tsx | 1 -
.../components/chat/ChatInputGroup.tsx | 11 ++---
surfsense_web/hooks/use-documents.ts | 5 +-
surfsense_web/lib/pagination.ts | 47 +++++++++----------
5 files changed, 30 insertions(+), 38 deletions(-)
diff --git a/surfsense_backend/app/schemas/documents.py b/surfsense_backend/app/schemas/documents.py
index 80f6dc0a7..fc83d24be 100644
--- a/surfsense_backend/app/schemas/documents.py
+++ b/surfsense_backend/app/schemas/documents.py
@@ -1,7 +1,7 @@
from datetime import datetime
+from typing import TypeVar
from pydantic import BaseModel, ConfigDict
-from typing import Generic, TypeVar
from app.db import DocumentType
@@ -58,6 +58,6 @@ class DocumentWithChunksRead(DocumentRead):
model_config = ConfigDict(from_attributes=True)
-class PaginatedResponse(BaseModel, Generic[T]):
+class PaginatedResponse[T](BaseModel):
items: list[T]
total: int
diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx
index ce7fc3b50..86e702843 100644
--- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx
+++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx
@@ -173,7 +173,6 @@ export default function DocumentsTable() {
/>
-export { DocumentsTable };
{
diff --git a/surfsense_web/hooks/use-documents.ts b/surfsense_web/hooks/use-documents.ts
index 091149641..4c22a8707 100644
--- a/surfsense_web/hooks/use-documents.ts
+++ b/surfsense_web/hooks/use-documents.ts
@@ -37,10 +37,7 @@ export interface UseDocumentsOptions {
lazy?: boolean;
}
-export function useDocuments(
- searchSpaceId: number,
- options?: UseDocumentsOptions | boolean
-) {
+export function useDocuments(searchSpaceId: number, options?: UseDocumentsOptions | boolean) {
// Support both old boolean API and new options API for backward compatibility
const opts = typeof options === "boolean" ? { lazy: options } : options || {};
const { page, pageSize = 300, lazy = false } = opts;
diff --git a/surfsense_web/lib/pagination.ts b/surfsense_web/lib/pagination.ts
index 0c618e56e..f9cabc64b 100644
--- a/surfsense_web/lib/pagination.ts
+++ b/surfsense_web/lib/pagination.ts
@@ -1,34 +1,33 @@
// Helper to normalize list responses from the API
// Supports shapes: Array, { items: T[]; total: number }, and tuple [T[], total]
export type ListResponse = {
- items: T[];
- total: number;
+ items: T[];
+ total: number;
};
export function normalizeListResponse(payload: any): ListResponse {
- try {
- // Case 1: already in desired shape
- if (payload && Array.isArray(payload.items)) {
- const total = typeof payload.total === "number" ? payload.total : payload.items.length;
- return { items: payload.items as T[], total };
- }
+ try {
+ // Case 1: already in desired shape
+ if (payload && Array.isArray(payload.items)) {
+ const total = typeof payload.total === "number" ? payload.total : payload.items.length;
+ return { items: payload.items as T[], total };
+ }
- // Case 2: tuple [items, total]
- if (Array.isArray(payload) && payload.length === 2 && Array.isArray(payload[0])) {
- const items = (payload[0] ?? []) as T[];
- const rawTotal = payload[1];
- const total = typeof rawTotal === "number" ? rawTotal : items.length;
- return { items, total };
- }
+ // Case 2: tuple [items, total]
+ if (Array.isArray(payload) && payload.length === 2 && Array.isArray(payload[0])) {
+ const items = (payload[0] ?? []) as T[];
+ const rawTotal = payload[1];
+ const total = typeof rawTotal === "number" ? rawTotal : items.length;
+ return { items, total };
+ }
- // Case 3: plain array
- if (Array.isArray(payload)) {
- return { items: payload as T[], total: (payload as T[]).length };
- }
- } catch (e) {
- // fallthrough to default
- }
+ // Case 3: plain array
+ if (Array.isArray(payload)) {
+ return { items: payload as T[], total: (payload as T[]).length };
+ }
+ } catch (e) {
+ // fallthrough to default
+ }
- return { items: [], total: 0 };
+ return { items: [], total: 0 };
}
-
From 3adabe7a1687d77a53269619fd5a081ea83d6d63 Mon Sep 17 00:00:00 2001
From: "DESKTOP-RTLN3BA\\$punk"
Date: Thu, 2 Oct 2025 12:43:43 -0700
Subject: [PATCH 10/25] feat: frontpage
---
surfsense_web/app/page.tsx | 6 +-
.../ModernHeroWithGradients.tsx | 7 +-
surfsense_web/components/homepage/cta.tsx | 110 +++++
.../homepage/features-bento-grid.tsx | 448 ++++++++++++++++++
.../components/homepage/features-card.tsx | 74 +++
surfsense_web/components/ui/bento-grid.tsx | 54 +++
.../public/homepage/comments-audio.webp | Bin 0 -> 12336 bytes
7 files changed, 696 insertions(+), 3 deletions(-)
rename surfsense_web/components/{ => homepage}/ModernHeroWithGradients.tsx (98%)
create mode 100644 surfsense_web/components/homepage/cta.tsx
create mode 100644 surfsense_web/components/homepage/features-bento-grid.tsx
create mode 100644 surfsense_web/components/homepage/features-card.tsx
create mode 100644 surfsense_web/components/ui/bento-grid.tsx
create mode 100644 surfsense_web/public/homepage/comments-audio.webp
diff --git a/surfsense_web/app/page.tsx b/surfsense_web/app/page.tsx
index e5501d35f..dfa0e7587 100644
--- a/surfsense_web/app/page.tsx
+++ b/surfsense_web/app/page.tsx
@@ -1,7 +1,9 @@
"use client";
import { Footer } from "@/components/Footer";
-import { ModernHeroWithGradients } from "@/components/ModernHeroWithGradients";
+import { CTAHomepage } from "@/components/homepage/cta";
+import { FeaturesBentoGrid } from "@/components/homepage/features-bento-grid";
+import { ModernHeroWithGradients } from "@/components/homepage/ModernHeroWithGradients";
import { Navbar } from "@/components/Navbar";
export default function HomePage() {
@@ -9,6 +11,8 @@ export default function HomePage() {
+
+
);
diff --git a/surfsense_web/components/ModernHeroWithGradients.tsx b/surfsense_web/components/homepage/ModernHeroWithGradients.tsx
similarity index 98%
rename from surfsense_web/components/ModernHeroWithGradients.tsx
rename to surfsense_web/components/homepage/ModernHeroWithGradients.tsx
index 543e1b4eb..bb7a49d74 100644
--- a/surfsense_web/components/ModernHeroWithGradients.tsx
+++ b/surfsense_web/components/homepage/ModernHeroWithGradients.tsx
@@ -1,12 +1,14 @@
"use client";
import { IconBrandDiscord, IconBrandGithub, IconFileTypeDoc, IconMail } from "@tabler/icons-react";
+import Image from "next/image";
import Link from "next/link";
import { cn } from "@/lib/utils";
-import { Logo } from "./Logo";
+import { Logo } from "../Logo";
+import { Features } from "./features-card";
export function ModernHeroWithGradients() {
return (
-
+
@@ -77,6 +79,7 @@ export function ModernHeroWithGradients() {
+
diff --git a/surfsense_web/components/homepage/cta.tsx b/surfsense_web/components/homepage/cta.tsx
new file mode 100644
index 000000000..1176c69df
--- /dev/null
+++ b/surfsense_web/components/homepage/cta.tsx
@@ -0,0 +1,110 @@
+"use client";
+import { IconMessageCircleQuestion } from "@tabler/icons-react";
+import Link from "next/link";
+import type React from "react";
+import { cn } from "@/lib/utils";
+
+export function CTAHomepage() {
+ return (
+
+
+
+
+
+
+
+ Transform how your team{" "}
+ discovers and collaborates
+
+
+ Unite your team's knowledge in one collaborative
+ space with intelligent search.
+
+
+
+
+
+ Talk to us
+
+
+
+
+
+ {/*
+
+ "SurfSense has revolutionized how our team shares and discovers knowledge.
+ Everyone can contribute and find what they need instantly. True collaboration at scale."
+