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 1d713930a..adca05b91 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -191,7 +191,7 @@ export function DashboardClientLayout({ }> {children} - {/* Global connector dialog - triggered from documents page */} + {/* Global connector dialog */} ); 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 deleted file mode 100644 index 824460a5d..000000000 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/page.tsx +++ /dev/null @@ -1,317 +0,0 @@ -"use client"; - -import { useQuery } from "@tanstack/react-query"; -import { useAtomValue } from "jotai"; -import { motion } from "motion/react"; -import { useParams } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useMemo, useState } from "react"; -import { toast } from "sonner"; -import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; -import type { DocumentTypeEnum } from "@/contracts/types/document.types"; -import { useDocuments } from "@/hooks/use-documents"; -import { documentsApiService } from "@/lib/apis/documents-api.service"; -import { cacheKeys } from "@/lib/query-client/cache-keys"; -import { DocumentsFilters } from "./components/DocumentsFilters"; -import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell"; -import { PAGE_SIZE, PaginationControls } from "./components/PaginationControls"; -import type { ColumnVisibility } from "./components/types"; - -function useDebounced(value: T, delay = 250) { - const [debounced, setDebounced] = useState(value); - useEffect(() => { - const t = setTimeout(() => setDebounced(value), delay); - return () => clearTimeout(t); - }, [value, delay]); - return debounced; -} - -export default function DocumentsTable() { - const t = useTranslations("documents"); - const params = useParams(); - const searchSpaceId = Number(params.search_space_id); - - const [search, setSearch] = useState(""); - const debouncedSearch = useDebounced(search, 250); - const [activeTypes, setActiveTypes] = useState([]); - const [columnVisibility, setColumnVisibility] = useState({ - document_type: true, - created_by: true, - created_at: true, - status: true, - }); - const [pageIndex, setPageIndex] = useState(0); - const [sortKey, setSortKey] = useState("created_at"); - const [sortDesc, setSortDesc] = useState(true); - const [selectedIds, setSelectedIds] = useState>(new Set()); - const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); - - // REAL-TIME: Use Electric SQL hook for live document updates (when not searching) - const { - documents: realtimeDocuments, - typeCounts: realtimeTypeCounts, - loading: realtimeLoading, - error: realtimeError, - } = useDocuments(searchSpaceId, activeTypes); - - // Check if we're in search mode - const isSearchMode = !!debouncedSearch.trim(); - - // Build search query parameters (only used when searching) - const searchQueryParams = useMemo( - () => ({ - search_space_id: searchSpaceId, - page: pageIndex, - page_size: PAGE_SIZE, - title: debouncedSearch.trim(), - ...(activeTypes.length > 0 && { document_types: activeTypes }), - }), - [searchSpaceId, pageIndex, activeTypes, debouncedSearch] - ); - - // API search query (only enabled when searching - Electric doesn't do full-text search) - const { - data: searchResponse, - isLoading: isSearchLoading, - refetch: refetchSearch, - error: searchError, - } = useQuery({ - queryKey: cacheKeys.documents.globalQueryParams(searchQueryParams), - queryFn: () => documentsApiService.searchDocuments({ queryParams: searchQueryParams }), - staleTime: 30 * 1000, // 30 seconds for search (shorter since it's on-demand) - enabled: !!searchSpaceId && isSearchMode, - }); - - // Client-side sorting for real-time documents - const sortedRealtimeDocuments = useMemo(() => { - const docs = [...realtimeDocuments]; - docs.sort((a, b) => { - const av = a[sortKey] ?? ""; - const bv = b[sortKey] ?? ""; - let cmp: number; - if (sortKey === "created_at") { - cmp = new Date(av as string).getTime() - new Date(bv as string).getTime(); - } else { - cmp = String(av).localeCompare(String(bv)); - } - return sortDesc ? -cmp : cmp; - }); - return docs; - }, [realtimeDocuments, sortKey, sortDesc]); - - // Client-side pagination for real-time documents - const paginatedRealtimeDocuments = useMemo(() => { - const start = pageIndex * PAGE_SIZE; - const end = start + PAGE_SIZE; - return sortedRealtimeDocuments.slice(start, end); - }, [sortedRealtimeDocuments, pageIndex]); - - // Determine what to display based on search mode - const displayDocs = isSearchMode - ? (searchResponse?.items || []).map((item) => ({ - id: item.id, - search_space_id: item.search_space_id, - document_type: item.document_type, - title: item.title, - created_by_id: item.created_by_id ?? null, - created_by_name: item.created_by_name ?? null, - created_by_email: item.created_by_email ?? null, - created_at: item.created_at, - status: ( - item as { - status?: { state: "ready" | "pending" | "processing" | "failed"; reason?: string }; - } - ).status ?? { state: "ready" as const }, - })) - : paginatedRealtimeDocuments; - - const displayTotal = isSearchMode ? searchResponse?.total || 0 : sortedRealtimeDocuments.length; - - const loading = isSearchMode ? isSearchLoading : realtimeLoading; - const error = isSearchMode ? searchError : realtimeError; - - const pageEnd = Math.min((pageIndex + 1) * PAGE_SIZE, displayTotal); - - const onToggleType = (type: DocumentTypeEnum, checked: boolean) => { - setActiveTypes((prev) => { - if (checked) { - return prev.includes(type) ? prev : [...prev, type]; - } else { - return prev.filter((t) => t !== type); - } - }); - setPageIndex(0); - // Clear selections when filter changes — selected IDs from the previous - // filter view are no longer visible and would cause misleading bulk actions - setSelectedIds(new Set()); - }; - - const onBulkDelete = async () => { - if (selectedIds.size === 0) { - toast.error(t("no_rows_selected")); - return; - } - - // Filter out pending/processing documents - they cannot be deleted - // For real-time mode, use sortedRealtimeDocuments (which has status) - // For search mode, use searchResponse items (need to safely access status) - const allDocs = isSearchMode - ? (searchResponse?.items || []).map((item) => ({ - id: item.id, - status: (item as { status?: { state: string } }).status, - })) - : sortedRealtimeDocuments.map((doc) => ({ id: doc.id, status: doc.status })); - - const selectedDocs = allDocs.filter((doc) => selectedIds.has(doc.id)); - const deletableIds = selectedDocs - .filter((doc) => doc.status?.state !== "pending" && doc.status?.state !== "processing") - .map((doc) => doc.id); - const inProgressCount = selectedIds.size - deletableIds.length; - - if (inProgressCount > 0) { - toast.warning( - `${inProgressCount} document(s) are pending or processing and cannot be deleted.` - ); - } - - if (deletableIds.length === 0) { - return; - } - - try { - // Delete documents one by one using the mutation - // Track 409 conflicts separately (document started processing after UI loaded) - let conflictCount = 0; - const results = await Promise.all( - deletableIds.map(async (id) => { - try { - await deleteDocumentMutation({ id }); - return true; - } catch (error: unknown) { - const status = - (error as { response?: { status?: number } })?.response?.status ?? - (error as { status?: number })?.status; - if (status === 409) conflictCount++; - return false; - } - }) - ); - const okCount = results.filter((r) => r === true).length; - if (okCount === deletableIds.length) { - toast.success(t("delete_success_count", { count: okCount })); - } else if (conflictCount > 0) { - toast.error(`${conflictCount} document(s) started processing. Please try again later.`); - } else { - toast.error(t("delete_partial_failed")); - } - - // If in search mode, refetch search results to reflect deletion - if (isSearchMode) { - await refetchSearch(); - } - // Real-time mode: Electric will sync the deletion automatically - - setSelectedIds(new Set()); - } catch (e) { - console.error(e); - toast.error(t("delete_error")); - } - }; - - // Single document delete handler for RowActions - const handleDeleteDocument = useCallback( - async (id: number): Promise => { - try { - await deleteDocumentMutation({ id }); - toast.success(t("delete_success") || "Document deleted"); - // If in search mode, refetch search results to reflect deletion - if (isSearchMode) { - await refetchSearch(); - } - // Real-time mode: Electric will sync the deletion automatically - return true; - } catch (e) { - console.error("Error deleting document:", e); - return false; - } - }, - [deleteDocumentMutation, isSearchMode, refetchSearch, t] - ); - - const handleSortChange = useCallback((key: SortKey) => { - setSortKey((currentKey) => { - if (currentKey === key) { - setSortDesc((v) => !v); - return currentKey; - } - setSortDesc(false); - return key; - }); - }, []); - - // Reset page when search changes (type filter already resets via onToggleType) - // biome-ignore lint/correctness/useExhaustiveDependencies: Intentionally reset page on search change - useEffect(() => { - setPageIndex(0); - }, [debouncedSearch]); - - useEffect(() => { - const mq = window.matchMedia("(max-width: 768px)"); - const apply = (isSmall: boolean) => { - setColumnVisibility((prev) => ({ ...prev, created_by: !isSmall, created_at: !isSmall })); - }; - apply(mq.matches); - const onChange = (e: MediaQueryListEvent) => apply(e.matches); - mq.addEventListener("change", onChange); - return () => mq.removeEventListener("change", onChange); - }, []); - - return ( - - {/* Filters - use real-time type counts */} - - - {/* Table */} - - - {/* Pagination */} - setPageIndex(0)} - onPrev={() => setPageIndex((i) => Math.max(0, i - 1))} - onNext={() => setPageIndex((i) => (pageEnd < displayTotal ? i + 1 : i))} - onLast={() => setPageIndex(Math.max(0, Math.ceil(displayTotal / PAGE_SIZE) - 1))} - canPrev={pageIndex > 0} - canNext={pageEnd < displayTotal} - /> - - ); -} - -export { DocumentsTable }; 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), });