diff --git a/surfsense_backend/app/routes/documents_routes.py b/surfsense_backend/app/routes/documents_routes.py index be90df459..d25a2db48 100644 --- a/surfsense_backend/app/routes/documents_routes.py +++ b/surfsense_backend/app/routes/documents_routes.py @@ -211,7 +211,11 @@ async def read_documents( Permission.DOCUMENTS_READ.value, "You don't have permission to read documents in this search space", ) - query = select(Document).filter(Document.search_space_id == search_space_id) + query = ( + select(Document) + .options(selectinload(Document.created_by)) + .filter(Document.search_space_id == search_space_id) + ) count_query = ( select(func.count()) .select_from(Document) @@ -221,6 +225,7 @@ async def read_documents( # Get documents from all search spaces user has membership in query = ( select(Document) + .options(selectinload(Document.created_by)) .join(SearchSpace) .join(SearchSpaceMembership) .filter(SearchSpaceMembership.user_id == user.id) @@ -261,6 +266,11 @@ async def read_documents( # Convert database objects to API-friendly format api_documents = [] for doc in db_documents: + # Get user name (display_name or email fallback) + created_by_name = None + if doc.created_by: + created_by_name = doc.created_by.display_name or doc.created_by.email + api_documents.append( DocumentRead( id=doc.id, @@ -273,6 +283,8 @@ async def read_documents( created_at=doc.created_at, updated_at=doc.updated_at, search_space_id=doc.search_space_id, + created_by_id=doc.created_by_id, + created_by_name=created_by_name, ) ) @@ -341,7 +353,11 @@ async def search_documents( Permission.DOCUMENTS_READ.value, "You don't have permission to read documents in this search space", ) - query = select(Document).filter(Document.search_space_id == search_space_id) + query = ( + select(Document) + .options(selectinload(Document.created_by)) + .filter(Document.search_space_id == search_space_id) + ) count_query = ( select(func.count()) .select_from(Document) @@ -351,6 +367,7 @@ async def search_documents( # Get documents from all search spaces user has membership in query = ( select(Document) + .options(selectinload(Document.created_by)) .join(SearchSpace) .join(SearchSpaceMembership) .filter(SearchSpaceMembership.user_id == user.id) @@ -395,6 +412,11 @@ async def search_documents( # Convert database objects to API-friendly format api_documents = [] for doc in db_documents: + # Get user name (display_name or email fallback) + created_by_name = None + if doc.created_by: + created_by_name = doc.created_by.display_name or doc.created_by.email + api_documents.append( DocumentRead( id=doc.id, @@ -407,6 +429,8 @@ async def search_documents( created_at=doc.created_at, updated_at=doc.updated_at, search_space_id=doc.search_space_id, + created_by_id=doc.created_by_id, + created_by_name=created_by_name, ) ) diff --git a/surfsense_backend/app/schemas/documents.py b/surfsense_backend/app/schemas/documents.py index 1f82ae9ce..ad1907b90 100644 --- a/surfsense_backend/app/schemas/documents.py +++ b/surfsense_backend/app/schemas/documents.py @@ -53,6 +53,7 @@ class DocumentRead(BaseModel): updated_at: datetime | None search_space_id: int created_by_id: UUID | None = None # User who created/uploaded this document + created_by_name: str | None = None # Display name or email of the user who created this document model_config = ConfigDict(from_attributes=True) 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 b9ddb9b74..83a579970 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -13,6 +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"; @@ -192,6 +193,8 @@ export function DashboardClientLayout({ }> {children} + {/* Global connector dialog - triggered from documents page */} + ); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx index e483dea12..246cff1c0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx @@ -2,6 +2,7 @@ import type React from "react"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; export function getDocumentTypeIcon(type: string): React.ReactNode { return getConnectorIcon(type); @@ -14,17 +15,35 @@ export function getDocumentTypeLabel(type: string): string { .join(" "); } +const MAX_LABEL_LENGTH = 28; + export function DocumentTypeChip({ type, className }: { type: string; className?: string }) { const icon = getDocumentTypeIcon(type); - return ( + const fullLabel = getDocumentTypeLabel(type); + const truncatedLabel = fullLabel.length > MAX_LABEL_LENGTH + ? `${fullLabel.slice(0, MAX_LABEL_LENGTH)}...` + : fullLabel; + const needsTruncation = fullLabel.length > MAX_LABEL_LENGTH; + + const chip = ( - {icon} - {getDocumentTypeLabel(type)} + {icon} + {truncatedLabel} ); + + if (needsTruncation) { + return ( + + {chip} + +

{fullLabel}

+
+
+ ); + } + + return chip; } 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 67413d6f0..87d349e38 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,9 +1,20 @@ "use client"; -import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react"; -import { AnimatePresence, motion, type Variants } from "motion/react"; +import { useSetAtom } from "jotai"; +import { + CircleAlert, + CircleX, + Columns3, + FilePlus2, + FileType, + SlidersHorizontal, + Trash, +} from "lucide-react"; +import { motion } from "motion/react"; import { useTranslations } from "next-intl"; import React, { useMemo, useRef } from "react"; +import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; +import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { AlertDialog, AlertDialogAction, @@ -17,25 +28,13 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuLabel, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; +import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon"; import type { ColumnVisibility } from "./types"; -const fadeInScale: Variants = { - hidden: { opacity: 0, scale: 0.95 }, - visible: { opacity: 1, scale: 1, transition: { type: "spring", stiffness: 300, damping: 30 } }, - exit: { opacity: 0, scale: 0.95, transition: { duration: 0.15 } }, -}; - export function DocumentsFilters({ typeCounts: typeCountsRecord, selectedIds, @@ -61,6 +60,10 @@ 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 uniqueTypes = useMemo(() => { return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[]; }, [typeCountsRecord]); @@ -75,14 +78,41 @@ export function DocumentsFilters({ return ( -
+ {/* Main toolbar row */} +
+ {/* Action Buttons - Left Side */} +
+ + +
+ + {/* Spacer */} +
+ + {/* Search Input */} onSearch(e.target.value)} - placeholder={t("filter_placeholder")} + placeholder="Filter by title" type="text" aria-label={t("filter_placeholder")} /> - - {Boolean(searchValue) && ( { onSearch(""); inputRef.current?.focus(); }} - initial={{ opacity: 0, rotate: -90 }} - animate={{ opacity: 1, rotate: 0 }} - exit={{ opacity: 0, rotate: 90 }} + 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 }} > - )} - - - - - - - - -
-
Filters
-
- - {uniqueTypes.map((value: DocumentTypeEnum, i) => ( - + +
+
+ Filter by source +
+
+ {uniqueTypes.map((value: DocumentTypeEnum, i) => ( + + ))} +
+ {activeTypes.length > 0 && ( +
+ +
+ )} +
+
+ + + {/* View/Columns Popover */} + + + + + +
+
+ Toggle columns +
+
+ {( + [ + ["document_type", "Source"], + ["created_by", "User"], + ["created_at", "Created"], + ] as Array<[keyof ColumnVisibility, string]> + ).map(([key, label], i) => ( + + ))}
-
- - + + +
- - - - - - - - Toggle columns - {( - [ - ["title", "Title"], - ["document_type", "Type"], - ["content", "Content"], - ["created_at", "Created At"], - ] as Array<[keyof ColumnVisibility, string]> - ).map(([key, label]) => ( - onToggleColumn(key, !!v)} - onSelect={(e) => e.preventDefault()} - > - {label} - - ))} - - -
- -
+ {/* Bulk Delete Button */} {selectedIds.size > 0 && ( - + + + - -
+ +
- - Are you absolutely sure? + + Delete {selectedIds.size} document{selectedIds.size !== 1 ? "s" : ""}? - This action cannot be undone. This will permanently delete {selectedIds.size}{" "} - selected {selectedIds.size === 1 ? "row" : "rows"}. + This action cannot be undone. This will permanently delete the selected {selectedIds.size === 1 ? "document" : "documents"} from your search space.
Cancel - Delete + + Delete +
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 d9908f46c..faa7605a3 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 @@ -4,9 +4,10 @@ import { ChevronDown, ChevronUp, FileX, Plus } from "lucide-react"; import { motion } from "motion/react"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import React from "react"; +import React, { useState } from "react"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { DocumentViewer } from "@/components/document-viewer"; +import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Spinner } from "@/components/ui/spinner"; @@ -19,7 +20,7 @@ import { TableRow, } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { DocumentTypeChip, getDocumentTypeIcon } from "./DocumentTypeIcon"; +import { DocumentTypeChip } from "./DocumentTypeIcon"; import { RowActions } from "./RowActions"; import type { ColumnVisibility, Document } from "./types"; @@ -36,13 +37,45 @@ function sortDocuments(docs: Document[], key: SortKey, desc: boolean): Document[ return desc ? sorted.reverse() : sorted; } -function truncate(text: string, len = 150): string { - const plain = text - .replace(/[#*_`>\-[\]()]+/g, " ") - .replace(/\s+/g, " ") - .trim(); - if (plain.length <= len) return plain; - return `${plain.slice(0, len)}...`; +function formatDate(dateStr: string): string { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +function SortableHeader({ + children, + sortKey, + currentSortKey, + sortDesc, + onSort, +}: { + children: React.ReactNode; + sortKey: SortKey; + currentSortKey: SortKey; + sortDesc: boolean; + onSort: (key: SortKey) => void; +}) { + const isActive = currentSortKey === sortKey; + return ( + + ); } export function DocumentsTableShell({ @@ -75,6 +108,9 @@ export function DocumentsTableShell({ const searchSpaceId = params.search_space_id; const { openDialog } = useDocumentUploadDialog(); + // State for metadata viewer (opened via Ctrl/Cmd+Click) + const [metadataDoc, setMetadataDoc] = useState(null); + const sorted = React.useMemo( () => sortDocuments(documents, sortKey, sortDesc), [documents, sortKey, sortDesc] @@ -107,23 +143,23 @@ export function DocumentsTableShell({ return ( {loading ? (
-
+

{t("loading")}

) : error ? (
-
+

{t("error_loading")}

-
@@ -136,10 +172,10 @@ export function DocumentsTableShell({ transition={{ duration: 0.4 }} className="flex flex-col items-center gap-4 max-w-md px-4 text-center" > -
- +
+
-
+

{t("no_documents")}

Get started by uploading your first document. @@ -153,218 +189,232 @@ export function DocumentsTableShell({

) : ( <> -
- - - - + {/* Desktop Table View */} +
+ {/* Fixed Header */} +
+ + + toggleAll(!!v)} aria-label="Select all" + className="data-[state=checked]:bg-primary data-[state=checked]:border-primary" /> - {columnVisibility.title && ( - - - - )} + + + Document + + {columnVisibility.document_type && ( - - + Source + )} - {columnVisibility.content && ( - {t("content_summary")} + {columnVisibility.created_by && ( + + User + )} {columnVisibility.created_at && ( - - + Created + )} - + Actions - - {sorted.map((doc, index) => { - const icon = getDocumentTypeIcon(doc.document_type); - const title = doc.title; - const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title; - return ( - - - toggleOne(doc.id, !!v)} - aria-label="Select row" - /> - - {columnVisibility.title && ( - - - - - - {icon} - {truncatedTitle} - - - -

{title}

-
-
-
-
- )} - {columnVisibility.document_type && ( - -
- -
-
- )} - {columnVisibility.content && ( - -
-
- {truncate(doc.content)} -
- - {t("view_full")} - - } - /> -
-
- )} - {columnVisibility.created_at && ( - - {new Date(doc.created_at).toLocaleDateString()} - - )} - - { - await onRefresh(); - }} - searchSpaceId={searchSpaceId as string} - /> - -
- ); - })} -
+ {/* Scrollable Body */} +
+ + + {sorted.map((doc, index) => { + const title = doc.title; + const truncatedTitle = title.length > 50 ? `${title.slice(0, 50)}...` : title; + const isSelected = selectedIds.has(doc.id); + return ( + + + toggleOne(doc.id, !!v)} + aria-label="Select row" + className="data-[state=checked]:bg-primary data-[state=checked]:border-primary" + /> + + + { + // Ctrl (Win/Linux) or Cmd (Mac) + Click opens metadata + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + setMetadataDoc(doc); + } + }} + onKeyDown={(e) => { + // Ctrl/Cmd + Enter opens metadata + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + setMetadataDoc(doc); + } + }} + > + {title.length > 50 ? ( + + + {truncatedTitle} + + +

{title}

+
+
+ ) : ( + title + )} + + } + /> +
+ {columnVisibility.document_type && ( + + + + )} + {columnVisibility.created_by && ( + + {doc.created_by_name || "—"} + + )} + {columnVisibility.created_at && ( + + {formatDate(doc.created_at)} + + )} + + { + await onRefresh(); + }} + searchSpaceId={searchSpaceId as string} + /> + +
+ ); + })} +
+
+
-
- {sorted.map((doc) => { - const icon = getDocumentTypeIcon(doc.document_type); + + {/* Mobile Card View */} +
+ {sorted.map((doc, index) => { + const isSelected = selectedIds.has(doc.id); return ( -
-
+ +
toggleOne(doc.id, !!v)} aria-label="Select row" + className="mt-0.5 data-[state=checked]:bg-primary data-[state=checked]:border-primary" /> -
-
- {icon} -
{doc.title}
-
-
- - - {new Date(doc.created_at).toLocaleDateString()} - -
- {columnVisibility.content && ( -
- {truncate(doc.content)} -
- - {t("view_full")} - +
+ { + // Ctrl (Win/Linux) or Cmd (Mac) + Click opens metadata + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + setMetadataDoc(doc); } - /> -
-
- )} + }} + onKeyDown={(e) => { + // Ctrl/Cmd + Enter opens metadata + if ((e.ctrlKey || e.metaKey) && e.key === "Enter") { + e.preventDefault(); + setMetadataDoc(doc); + } + }} + > + {doc.title} + + } + /> +
+ + {columnVisibility.created_by && doc.created_by_name && ( + + {doc.created_by_name} + + )} + {columnVisibility.created_at && ( + + {formatDate(doc.created_at)} + + )} +
-
+
); })}
)} + + {/* Metadata Viewer - opened via Ctrl/Cmd+Click on document title */} + { + if (!open) setMetadataDoc(null); + }} + /> ); } diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls.tsx index d87fa2dc9..bd8a9f1cc 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls.tsx @@ -2,164 +2,89 @@ import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react"; import { motion } from "motion/react"; -import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; + +const PAGE_SIZE = 50; export function PaginationControls({ pageIndex, - pageSize, total, - onPageSizeChange, onFirst, onPrev, onNext, onLast, canPrev, canNext, - id, }: { pageIndex: number; - pageSize: number; total: number; - onPageSizeChange: (size: number) => void; onFirst: () => void; onPrev: () => void; onNext: () => void; onLast: () => void; canPrev: boolean; canNext: boolean; - id: string; }) { - const t = useTranslations("documents"); - const start = total === 0 ? 0 : pageIndex * pageSize + 1; - const end = Math.min((pageIndex + 1) * pageSize, total); + const start = pageIndex * PAGE_SIZE + 1; + const end = Math.min((pageIndex + 1) * PAGE_SIZE, total); return ( -
- - - - + + {/* Range indicator */} + + {start}-{end} of {total} + - -

- - {start}-{end} - {" "} - of {total} -

-
- -
- - - - - - - - - - - - - - - - - - - - - - - - + {/* Navigation buttons */} +
+ + + +
-
+
); } + +export { PAGE_SIZE }; 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 d9a894e5a..cc6ed3fe8 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 @@ -1,11 +1,10 @@ "use client"; -import { FileText, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; +import { MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { motion } from "motion/react"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { toast } from "sonner"; -import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; import { AlertDialog, AlertDialogAction, @@ -43,7 +42,6 @@ export function RowActions({ searchSpaceId: string; }) { const [isDeleteOpen, setIsDeleteOpen] = useState(false); - const [isMetadataOpen, setIsMetadataOpen] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const router = useRouter(); @@ -104,29 +102,6 @@ export function RowActions({ )} - - - - - - - -

View Metadata

-
-
- {isDeletable && ( @@ -170,10 +145,6 @@ export function RowActions({ Edit )} - setIsMetadataOpen(true)}> - - Metadata - {isDeletable && ( setIsDeleteOpen(true)} @@ -187,13 +158,6 @@ export function RowActions({
- - diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/types.ts b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/types.ts index 73b68b588..b52054dcd 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/types.ts +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/types.ts @@ -8,11 +8,12 @@ export type Document = { content: string; created_at: string; search_space_id: number; + created_by_id?: string | null; + created_by_name?: string | null; }; export type ColumnVisibility = { - title: boolean; document_type: boolean; - content: boolean; + created_by: boolean; created_at: boolean; }; 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 52eb3546c..269c2ca2f 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,22 +2,19 @@ import { useQuery } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; -import { RefreshCw, SquarePlus, Upload } from "lucide-react"; import { motion } from "motion/react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; -import { useCallback, useEffect, useId, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; -import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; -import { Button } from "@/components/ui/button"; import type { DocumentTypeEnum } from "@/contracts/types/document.types"; 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 { PaginationControls } from "./components/PaginationControls"; +import { PAGE_SIZE, PaginationControls } from "./components/PaginationControls"; import type { ColumnVisibility } from "./components/types"; function useDebounced(value: T, delay = 250) { @@ -31,29 +28,20 @@ function useDebounced(value: T, delay = 250) { export default function DocumentsTable() { const t = useTranslations("documents"); - const id = useId(); const params = useParams(); - const router = useRouter(); const searchSpaceId = Number(params.search_space_id); - const { openDialog: openUploadDialog } = useDocumentUploadDialog(); - - const handleNewNote = useCallback(() => { - router.push(`/dashboard/${searchSpaceId}/editor/new`); - }, [router, searchSpaceId]); const [search, setSearch] = useState(""); const debouncedSearch = useDebounced(search, 250); const [activeTypes, setActiveTypes] = useState([]); const [columnVisibility, setColumnVisibility] = useState({ - title: true, document_type: true, - content: true, + created_by: true, created_at: true, }); const [pageIndex, setPageIndex] = useState(0); - const [pageSize, setPageSize] = useState(50); - const [sortKey, setSortKey] = useState("title"); - const [sortDesc, setSortDesc] = useState(false); + const [sortKey, setSortKey] = useState("created_at"); + const [sortDesc, setSortDesc] = useState(true); const [selectedIds, setSelectedIds] = useState>(new Set()); const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); @@ -63,10 +51,10 @@ export default function DocumentsTable() { () => ({ search_space_id: searchSpaceId, page: pageIndex, - page_size: pageSize, + page_size: PAGE_SIZE, ...(activeTypes.length > 0 && { document_types: activeTypes }), }), - [searchSpaceId, pageIndex, pageSize, activeTypes] + [searchSpaceId, pageIndex, activeTypes] ); // Build search query parameters @@ -74,11 +62,11 @@ export default function DocumentsTable() { () => ({ search_space_id: searchSpaceId, page: pageIndex, - page_size: pageSize, + page_size: PAGE_SIZE, title: debouncedSearch.trim(), ...(activeTypes.length > 0 && { document_types: activeTypes }), }), - [searchSpaceId, pageIndex, pageSize, activeTypes, debouncedSearch] + [searchSpaceId, pageIndex, activeTypes, debouncedSearch] ); // Use query for fetching documents @@ -112,17 +100,14 @@ export default function DocumentsTable() { activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum); // Use query for fetching SurfSense docs - const { - data: surfsenseDocsResponse, - isLoading: isSurfsenseDocsLoading, - refetch: refetchSurfsenseDocs, - } = useQuery({ - queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize], + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { data: surfsenseDocsResponse } = useQuery({ + queryKey: ["surfsense-docs", debouncedSearch, pageIndex, PAGE_SIZE], queryFn: () => documentsApiService.getSurfsenseDocs({ queryParams: { page: pageIndex, - page_size: pageSize, + page_size: PAGE_SIZE, title: debouncedSearch.trim() || undefined, }, }), @@ -131,7 +116,8 @@ export default function DocumentsTable() { }); // Transform SurfSense docs to match the Document type - const surfsenseDocsAsDocuments: Document[] = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const surfsenseDocsAsDocuments = useMemo(() => { if (!surfsenseDocsResponse?.items) return []; return surfsenseDocsResponse.items.map((doc) => ({ id: doc.id, @@ -145,6 +131,7 @@ export default function DocumentsTable() { }, [surfsenseDocsResponse]); // Merge type counts with SURFSENSE_DOCS count + // eslint-disable-next-line @typescript-eslint/no-unused-vars const typeCounts = useMemo(() => { const counts = { ...(rawTypeCounts || {}) }; if (surfsenseDocsResponse?.total) { @@ -165,11 +152,17 @@ export default function DocumentsTable() { // Display results directly const displayDocs = documents; const displayTotal = total; - const pageStart = pageIndex * pageSize; - const pageEnd = Math.min(pageStart + pageSize, displayTotal); + const pageEnd = Math.min((pageIndex + 1) * PAGE_SIZE, displayTotal); const onToggleType = (type: DocumentTypeEnum, checked: boolean) => { - setActiveTypes((prev) => (checked ? [...prev, type] : prev.filter((t) => t !== type))); + setActiveTypes((prev) => { + if (checked) { + // Only add if not already in the array + return prev.includes(type) ? prev : [...prev, type]; + } else { + return prev.filter((t) => t !== type); + } + }); setPageIndex(0); }; @@ -238,10 +231,21 @@ export default function DocumentsTable() { } }; + const handleSortChange = useCallback((key: SortKey) => { + setSortKey((currentKey) => { + if (currentKey === key) { + setSortDesc((v) => !v); + return currentKey; + } + setSortDesc(false); + return key; + }); + }, []); + useEffect(() => { const mq = window.matchMedia("(max-width: 768px)"); const apply = (isSmall: boolean) => { - setColumnVisibility((prev) => ({ ...prev, content: !isSmall, created_at: !isSmall })); + setColumnVisibility((prev) => ({ ...prev, created_by: !isSmall, created_at: !isSmall })); }; apply(mq.matches); const onChange = (e: MediaQueryListEvent) => apply(e.matches); @@ -254,34 +258,9 @@ export default function DocumentsTable() { initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }} - className="w-full px-6 py-4 space-y-6 min-h-[calc(100vh-64px)]" + className="w-full max-w-7xl mx-auto px-6 pt-17 pb-6 space-y-6 min-h-[calc(100vh-64px)]" > - -
-

{t("title")}

-

{t("subtitle")}

-
-
- - - -
-
- + {/* Filters */} + {/* Table */} { - if (sortKey === key) setSortDesc((v) => !v); - else { - setSortKey(key); - setSortDesc(false); - } - }} + onSortChange={handleSortChange} /> + {/* Pagination */} { - setPageSize(s); - setPageIndex(0); - }} onFirst={() => 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 / pageSize) - 1))} + onLast={() => setPageIndex(Math.max(0, Math.ceil(displayTotal / PAGE_SIZE) - 1))} canPrev={pageIndex > 0} canNext={pageEnd < displayTotal} - id={id} /> ); diff --git a/surfsense_web/atoms/connector-dialog/connector-dialog.atoms.ts b/surfsense_web/atoms/connector-dialog/connector-dialog.atoms.ts new file mode 100644 index 000000000..38205a8d2 --- /dev/null +++ b/surfsense_web/atoms/connector-dialog/connector-dialog.atoms.ts @@ -0,0 +1,5 @@ +import { atom } from "jotai"; + +// Atom to control the connector dialog open state from anywhere in the app +export const connectorDialogOpenAtom = atom(false); + diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index 9b201e96b..abb32dde1 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -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 = () => { +export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger = false }) => { const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchParams = useSearchParams(); const { data: currentUser } = useAtomValue(currentUserAtom); @@ -186,34 +186,36 @@ export const ConnectorIndicator: FC = () => { return ( - handleOpenChange(true)} - > - {isLoading ? ( - - ) : ( - <> - - {activeConnectorsCount > 0 && ( - - {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount} - - )} - - )} - + {!hideTrigger && ( + handleOpenChange(true)} + > + {isLoading ? ( + + ) : ( + <> + + {activeConnectorsCount > 0 && ( + + {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount} + + )} + + )} + + )} Manage Connectors diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index 639d0f7ed..118ca66ce 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -1,8 +1,9 @@ import { format } from "date-fns"; -import { useAtomValue } from "jotai"; +import { useAtom, useAtomValue } from "jotai"; import { useRouter, useSearchParams } from "next/navigation"; import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; +import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms"; import { createConnectorMutationAtom, deleteConnectorMutationAtom, @@ -49,7 +50,8 @@ export const useConnectorDialog = () => { const { mutateAsync: deleteConnector } = useAtomValue(deleteConnectorMutationAtom); const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom); - const [isOpen, setIsOpen] = useState(false); + // Use global atom for dialog open state so it can be controlled from anywhere + const [isOpen, setIsOpen] = useAtom(connectorDialogOpenAtom); const [activeTab, setActiveTab] = useState("all"); const [connectingId, setConnectingId] = useState(null); const [isScrolled, setIsScrolled] = useState(false);