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 new file mode 100644 index 0000000..e0cc12b --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentTypeIcon.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { + IconBook, + IconBrandDiscord, + IconBrandGithub, + IconBrandNotion, + IconBrandSlack, + IconBrandYoutube, + IconCalendar, + IconChecklist, + IconLayoutKanban, + IconTicket, +} from "@tabler/icons-react"; +import { File, Globe, Webhook } from "lucide-react"; +import type React from "react"; + +type IconComponent = React.ComponentType<{ size?: number; className?: string }>; + +const documentTypeIcons: Record = { + EXTENSION: Webhook, + CRAWLED_URL: Globe, + SLACK_CONNECTOR: IconBrandSlack, + NOTION_CONNECTOR: IconBrandNotion, + FILE: File, + YOUTUBE_VIDEO: IconBrandYoutube, + GITHUB_CONNECTOR: IconBrandGithub, + LINEAR_CONNECTOR: IconLayoutKanban, + JIRA_CONNECTOR: IconTicket, + DISCORD_CONNECTOR: IconBrandDiscord, + CONFLUENCE_CONNECTOR: IconBook, + CLICKUP_CONNECTOR: IconChecklist, + GOOGLE_CALENDAR_CONNECTOR: IconCalendar, +}; + +export function getDocumentTypeIcon(type: string): IconComponent { + return documentTypeIcons[type] ?? File; +} + +export function getDocumentTypeLabel(type: string): string { + return type + .split("_") + .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) + .join(" "); +} + +export function DocumentTypeChip({ type, className }: { type: string; className?: string }) { + const Icon = getDocumentTypeIcon(type); + return ( + + + {getDocumentTypeLabel(type)} + + ); +} 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 new file mode 100644 index 0000000..dd87912 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsFilters.tsx @@ -0,0 +1,276 @@ +"use client"; + +import { AnimatePresence, motion, type Variants } from "framer-motion"; +import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react"; +import React, { useMemo, useRef } from "react"; +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 { + 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 { ColumnVisibility, Document } 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({ + allDocuments, + visibleDocuments: _visibleDocuments, + selectedIds, + onSearch, + searchValue, + onBulkDelete, + onToggleType, + activeTypes, + columnVisibility, + onToggleColumn, +}: { + allDocuments: Document[]; + visibleDocuments: Document[]; + selectedIds: Set; + onSearch: (v: string) => void; + searchValue: string; + onBulkDelete: () => Promise; + onToggleType: (type: string, checked: boolean) => void; + activeTypes: string[]; + columnVisibility: ColumnVisibility; + onToggleColumn: (id: keyof ColumnVisibility, checked: boolean) => void; +}) { + const id = React.useId(); + const inputRef = useRef(null); + + const uniqueTypes = useMemo(() => { + const set = new Set(); + for (const d of allDocuments) set.add(d.document_type); + return Array.from(set).sort(); + }, [allDocuments]); + + const typeCounts = useMemo(() => { + const map = new Map(); + for (const d of allDocuments) map.set(d.document_type, (map.get(d.document_type) ?? 0) + 1); + return map; + }, [allDocuments]); + + return ( + +
+ + onSearch(e.target.value)} + placeholder="Filter by title..." + type="text" + aria-label="Filter by title" + /> + + + {Boolean(searchValue) && ( + { + onSearch(""); + inputRef.current?.focus(); + }} + initial={{ opacity: 0, rotate: -90 }} + animate={{ opacity: 1, rotate: 0 }} + exit={{ opacity: 0, rotate: 90 }} + whileHover={{ scale: 1.1 }} + whileTap={{ scale: 0.9 }} + > + + )} + + + + + + + + + + +
+
Filters
+
+ + {uniqueTypes.map((value, i) => ( + + onToggleType(value, !!checked)} + /> + + + ))} + +
+
+
+
+
+ + + + + + + + + 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} + + ))} + + +
+ +
+ {selectedIds.size > 0 && ( + + + + + +
+ + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete {selectedIds.size}{" "} + selected {selectedIds.size === 1 ? "row" : "rows"}. + + +
+ + Cancel + 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 new file mode 100644 index 0000000..12da300 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -0,0 +1,355 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ChevronDown, ChevronUp, FileX } from "lucide-react"; +import React from "react"; +import { DocumentViewer } from "@/components/document-viewer"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { DocumentTypeChip, getDocumentTypeIcon } from "./DocumentTypeIcon"; +import { RowActions } from "./RowActions"; +import type { ColumnVisibility, Document } from "./types"; + +export type SortKey = keyof Pick; + +function sortDocuments(docs: Document[], key: SortKey, desc: boolean): Document[] { + const sorted = [...docs].sort((a, b) => { + const av = a[key] ?? ""; + const bv = b[key] ?? ""; + if (key === "created_at") + return new Date(av as string).getTime() - new Date(bv as string).getTime(); + return String(av).localeCompare(String(bv)); + }); + 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)}...`; +} + +export function DocumentsTableShell({ + documents, + loading, + error, + onRefresh, + selectedIds, + setSelectedIds, + columnVisibility, + deleteDocument, + sortKey, + sortDesc, + onSortChange, +}: { + documents: Document[]; + loading: boolean; + error: boolean; + onRefresh: () => Promise; + selectedIds: Set; + setSelectedIds: (update: Set) => void; + columnVisibility: ColumnVisibility; + deleteDocument: (id: number) => Promise; + sortKey: SortKey; + sortDesc: boolean; + onSortChange: (key: SortKey) => void; +}) { + const sorted = React.useMemo( + () => sortDocuments(documents, sortKey, sortDesc), + [documents, sortKey, sortDesc] + ); + + const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id)); + const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage; + + const toggleAll = (checked: boolean) => { + const next = new Set(selectedIds); + if (checked) sorted.forEach((d) => next.add(d.id)); + else sorted.forEach((d) => next.delete(d.id)); + setSelectedIds(next); + }; + + const toggleOne = (id: number, checked: boolean) => { + const next = new Set(selectedIds); + if (checked) next.add(id); + else next.delete(id); + setSelectedIds(next); + }; + + const onSortHeader = (key: SortKey) => onSortChange(key); + + return ( + + {loading ? ( +
+
+
+

Loading documents...

+
+
+ ) : error ? ( +
+
+

Error loading documents

+ +
+
+ ) : sorted.length === 0 ? ( +
+
+ +

No documents found

+
+
+ ) : ( + <> +
+ + + + + toggleAll(!!v)} + aria-label="Select all" + /> + + {columnVisibility.title && ( + + + + )} + {columnVisibility.document_type && ( + + + + )} + {columnVisibility.content && ( + Content Summary + )} + {columnVisibility.created_at && ( + + + + )} + + 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 && ( + + + + + + + {truncatedTitle} + + + +

{title}

+
+
+
+
+ )} + {columnVisibility.document_type && ( + +
+ +
+
+ )} + {columnVisibility.content && ( + +
+
+ {truncate(doc.content)} +
+ + View Full Content + + } + /> +
+
+ )} + {columnVisibility.created_at && ( + + {new Date(doc.created_at).toLocaleDateString()} + + )} + + { + await onRefresh(); + }} + /> + +
+ ); + })} +
+
+
+
+ {sorted.map((doc) => { + const Icon = getDocumentTypeIcon(doc.document_type); + return ( +
+
+ toggleOne(doc.id, !!v)} + aria-label="Select row" + /> +
+
+
+ +
{doc.title}
+
+ { + await onRefresh(); + }} + /> +
+
+ + + {new Date(doc.created_at).toLocaleDateString()} + +
+ {columnVisibility.content && ( +
+ {truncate(doc.content)} +
+ + View Full Content + + } + /> +
+
+ )} +
+
+
+ ); + })} +
+ + )} +
+ ); +} 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 new file mode 100644 index 0000000..2e6f8f3 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/PaginationControls.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { motion } from "framer-motion"; +import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react"; +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"; + +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 start = total === 0 ? 0 : pageIndex * pageSize + 1; + const end = Math.min((pageIndex + 1) * pageSize, total); + + return ( +
+ + + + + + +

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

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); +} 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 new file mode 100644 index 0000000..bd1e182 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/RowActions.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { MoreHorizontal } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { Document } from "./types"; + +export function RowActions({ + document, + deleteDocument, + refreshDocuments, +}: { + document: Document; + deleteDocument: (id: number) => Promise; + refreshDocuments: () => Promise; +}) { + const [isOpen, setIsOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + const handleDelete = async () => { + setIsDeleting(true); + try { + const ok = await deleteDocument(document.id); + if (ok) toast.success("Document deleted successfully"); + else toast.error("Failed to delete document"); + await refreshDocuments(); + } catch (error) { + console.error("Error deleting document:", error); + toast.error("Failed to delete document"); + } finally { + setIsDeleting(false); + setIsOpen(false); + } + }; + + return ( +
+ + + + + + e.preventDefault()}> + View Metadata + + } + /> + + + + { + e.preventDefault(); + setIsOpen(true); + }} + > + Delete + + + + + Are you sure? + + + Cancel + { + e.preventDefault(); + handleDelete(); + }} + disabled={isDeleting} + > + {isDeleting ? "Deleting..." : "Delete"} + + + + + + +
+ ); +} 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 new file mode 100644 index 0000000..73b68b5 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/types.ts @@ -0,0 +1,18 @@ +export type DocumentType = string; + +export type Document = { + id: number; + title: string; + document_type: DocumentType; + document_metadata: any; + content: string; + created_at: string; + search_space_id: number; +}; + +export type ColumnVisibility = { + title: boolean; + document_type: boolean; + content: 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 dd226c7..4a69a75 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 @@ -1,1067 +1,169 @@ "use client"; -import { - IconBook, - IconBrandDiscord, - IconBrandGithub, - IconBrandNotion, - IconBrandSlack, - IconBrandYoutube, - IconCalendar, - IconChecklist, - IconLayoutKanban, - IconTicket, -} from "@tabler/icons-react"; -import { - type ColumnDef, - type ColumnFiltersState, - flexRender, - getCoreRowModel, - getFacetedUniqueValues, - getFilteredRowModel, - getPaginationRowModel, - getSortedRowModel, - type PaginationState, - type Row, - type SortingState, - useReactTable, - type VisibilityState, -} from "@tanstack/react-table"; -import { AnimatePresence, motion, type Variants } from "framer-motion"; -import { - AlertCircle, - ChevronDown, - ChevronFirst, - ChevronLast, - ChevronLeft, - ChevronRight, - ChevronUp, - CircleAlert, - CircleX, - Columns3, - File, - FileX, - Filter, - Globe, - ListFilter, - MoreHorizontal, - Trash, - Webhook, -} from "lucide-react"; +import { motion } from "framer-motion"; import { useParams } from "next/navigation"; -import React, { useContext, useEffect, useId, useMemo, useRef, useState } from "react"; -import ReactMarkdown from "react-markdown"; -import rehypeRaw from "rehype-raw"; -import rehypeSanitize from "rehype-sanitize"; -import remarkGfm from "remark-gfm"; +import { useEffect, useId, useMemo, useState } from "react"; import { toast } from "sonner"; -import { DocumentViewer } from "@/components/document-viewer"; -import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; -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 { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + import { useDocuments } from "@/hooks/use-documents"; -import { cn } from "@/lib/utils"; -// Define animation variants for reuse -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 }, - }, -}; +import { DocumentsFilters } from "./components/DocumentsFilters"; +import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell"; +import { PaginationControls } from "./components/PaginationControls"; +import type { ColumnVisibility, Document } from "./components/types"; -type Document = { - id: number; - title: string; - document_type: - | "EXTENSION" - | "CRAWLED_URL" - | "SLACK_CONNECTOR" - | "NOTION_CONNECTOR" - | "FILE" - | "YOUTUBE_VIDEO" - | "LINEAR_CONNECTOR" - | "DISCORD_CONNECTOR"; - document_metadata: any; - content: string; - created_at: string; - search_space_id: number; -}; - -// Add document type icons mapping -const documentTypeIcons = { - EXTENSION: Webhook, - CRAWLED_URL: Globe, - SLACK_CONNECTOR: IconBrandSlack, - NOTION_CONNECTOR: IconBrandNotion, - FILE: File, - YOUTUBE_VIDEO: IconBrandYoutube, - GITHUB_CONNECTOR: IconBrandGithub, - LINEAR_CONNECTOR: IconLayoutKanban, - JIRA_CONNECTOR: IconTicket, - DISCORD_CONNECTOR: IconBrandDiscord, - CONFLUENCE_CONNECTOR: IconBook, - CLICKUP_CONNECTOR: IconChecklist, - GOOGLE_CALENDAR_CONNECTOR: IconCalendar, -} as const; - -const columns: ColumnDef[] = [ - { - id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - /> - ), - size: 28, - enableSorting: false, - enableHiding: false, - }, - { - header: "Title", - accessorKey: "title", - cell: ({ row }) => { - const Icon = documentTypeIcons[row.original.document_type]; - const title = row.getValue("title") as string; - const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title; - return ( - - - - - - {truncatedTitle} - - - -

{title}

-
-
-
- ); - }, - size: 250, - }, - { - header: "Type", - accessorKey: "document_type", - cell: ({ row }) => { - const type = row.getValue("document_type") as keyof typeof documentTypeIcons; - const Icon = documentTypeIcons[type]; - return ( -
-
- -
- - {type - .split("_") - .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) - .join(" ")} - -
- ); - }, - size: 180, - enableColumnFilter: true, - }, - { - header: "Content Summary", - accessorKey: "content", - cell: ({ row }) => { - const content = row.getValue("content") as string; - const title = row.getValue("title") as string; - - // Create a truncated preview (first 150 characters) - const previewContent = content.length > 150 ? `${content.substring(0, 150)}...` : content; - - return ( - - ); - }, - size: 300, - }, - { - header: "Created At", - accessorKey: "created_at", - cell: ({ row }) => { - const date = new Date(row.getValue("created_at")); - return date.toLocaleDateString(); - }, - size: 120, - }, - { - id: "actions", - header: () => Actions, - cell: ({ row }) => , - size: 60, - enableHiding: false, - }, -]; - -// Create a context to share the deleteDocument function -const DocumentsContext = React.createContext<{ - deleteDocument: (id: number) => Promise; - refreshDocuments: () => Promise; -} | null>(null); +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 id = useId(); const params = useParams(); const searchSpaceId = Number(params.search_space_id); + const { documents, loading, error, refreshDocuments, deleteDocument } = useDocuments(searchSpaceId); - // console.log("Search Space ID:", searchSpaceId); - // console.log("Documents loaded:", documents?.length); - - useEffect(() => { - console.log("Delete document function available:", !!deleteDocument); - }, [deleteDocument]); - - const [columnFilters, setColumnFilters] = useState([]); - const [columnVisibility, setColumnVisibility] = useState({}); - const [pagination, setPagination] = useState({ - pageIndex: 0, - pageSize: 10, - }); - const inputRef = useRef(null); - - const [sorting, setSorting] = useState([ - { - id: "title", - desc: false, - }, - ]); - const [data, setData] = useState([]); + 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_at: true, + }); + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(10); + const [sortKey, setSortKey] = useState("title"); + const [sortDesc, setSortDesc] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); useEffect(() => { - if (documents) { - setData(documents as Document[]); - } + if (documents) setData(documents as Document[]); }, [documents]); - const handleDeleteRows = async () => { - const selectedRows = table.getSelectedRowModel().rows; - // console.log("Deleting selected rows:", selectedRows.length); + const filtered = useMemo(() => { + let result = data; + if (debouncedSearch.trim()) { + const q = debouncedSearch.toLowerCase(); + result = result.filter((d) => d.title.toLowerCase().includes(q)); + } + if (activeTypes.length > 0) { + result = result.filter((d) => activeTypes.includes(d.document_type)); + } + return result; + }, [data, debouncedSearch, activeTypes]); - if (selectedRows.length === 0) { + const total = filtered.length; + const pageStart = pageIndex * pageSize; + const pageEnd = Math.min(pageStart + pageSize, total); + const pageDocs = filtered.slice(pageStart, pageEnd); + + const onToggleType = (type: string, checked: boolean) => { + setActiveTypes((prev) => (checked ? [...prev, type] : prev.filter((t) => t !== type))); + setPageIndex(0); + }; + + const onToggleColumn = (id: keyof ColumnVisibility, checked: boolean) => { + setColumnVisibility((prev) => ({ ...prev, [id]: checked })); + }; + + const onBulkDelete = async () => { + if (selectedIds.size === 0) { toast.error("No rows selected"); return; } - - // Create an array of promises for each delete operation - const deletePromises = selectedRows.map((row) => { - // console.log("Deleting row with ID:", row.original.id); - return deleteDocument(row.original.id); - }); - try { - // Execute all delete operations - const results = await Promise.all(deletePromises); - // console.log("Delete results:", results); - - // Check if all deletions were successful - const allSuccessful = results.every((result) => result === true); - - if (allSuccessful) { - toast.success(`Successfully deleted ${selectedRows.length} document(s)`); - } else { - toast.error("Some documents could not be deleted"); - } - - // Refresh the documents list after all deletions - await refreshDocuments(); - table.resetRowSelection(); - } catch (error: any) { - console.error("Error deleting documents:", error); + const results = await Promise.all(Array.from(selectedIds).map((id) => deleteDocument?.(id))); + const okCount = results.filter((r) => r === true).length; + if (okCount === selectedIds.size) + toast.success(`Successfully deleted ${okCount} document(s)`); + else toast.error("Some documents could not be deleted"); + await refreshDocuments?.(); + setSelectedIds(new Set()); + } catch (e) { + console.error(e); toast.error("Error deleting documents"); } }; - const table = useReactTable({ - data, - columns, - getCoreRowModel: getCoreRowModel(), - getSortedRowModel: getSortedRowModel(), - onSortingChange: setSorting, - enableSortingRemoval: false, - getPaginationRowModel: getPaginationRowModel(), - onPaginationChange: setPagination, - onColumnFiltersChange: setColumnFilters, - onColumnVisibilityChange: setColumnVisibility, - getFilteredRowModel: getFilteredRowModel(), - getFacetedUniqueValues: getFacetedUniqueValues(), - state: { - sorting, - pagination, - columnFilters, - columnVisibility, - }, - }); - - // Get unique status values - const uniqueStatusValues = useMemo(() => { - const statusColumn = table.getColumn("document_type"); - if (!data.length) return []; // Don't compute until data is present - - if (!statusColumn) return []; - - const values = Array.from(statusColumn.getFacetedUniqueValues().keys()); - - return values.sort(); - }, [table.getColumn, data]); - - // Get counts for each status - const statusCounts = useMemo(() => { - const statusColumn = table.getColumn("document_type"); - if (!data.length) return new Map(); // Don't compute until data is present - if (!statusColumn) return new Map(); - return statusColumn.getFacetedUniqueValues(); - }, [table.getColumn, data, columnFilters]); - - const selectedStatuses = useMemo(() => { - const filterValue = table.getColumn("document_type")?.getFilterValue() as string[]; - if (!data.length) return []; // Don't compute until data is present - - return filterValue ?? []; - }, [table.getColumn, data, columnFilters]); - - const handleStatusChange = (checked: boolean, value: string) => { - const filterValue = table.getColumn("document_type")?.getFilterValue() as string[]; - const newFilterValue = filterValue ? [...filterValue] : []; - - if (checked) { - newFilterValue.push(value); - } else { - const index = newFilterValue.indexOf(value); - if (index > -1) { - newFilterValue.splice(index, 1); - } - } - - setColumnFilters([ - { - id: "document_type", - value: newFilterValue, - }, - ]); - - table - .getColumn("document_type") - ?.setFilterValue(newFilterValue.length ? newFilterValue : undefined); - }; + useEffect(() => { + const mq = window.matchMedia("(max-width: 768px)"); + const apply = (isSmall: boolean) => { + setColumnVisibility((prev) => ({ ...prev, content: !isSmall, created_at: !isSmall })); + }; + apply(mq.matches); + const onChange = (e: MediaQueryListEvent) => apply(e.matches); + mq.addEventListener("change", onChange); + return () => mq.removeEventListener("change", onChange); + }, []); return ( - Promise.resolve(false)), - refreshDocuments: refreshDocuments || (() => Promise.resolve()), - }} + - - {/* Filters */} - -
- {/* Filter by name or email */} - - table.getColumn("title")?.setFilterValue(e.target.value)} - placeholder="Filter by title..." - type="text" - aria-label="Filter by title" - /> - - - {Boolean(table.getColumn("title")?.getFilterValue()) && ( - { - table.getColumn("title")?.setFilterValue(""); - if (inputRef.current) { - inputRef.current.focus(); - } - }} - initial={{ opacity: 0, rotate: -90 }} - animate={{ opacity: 1, rotate: 0 }} - exit={{ opacity: 0, rotate: 90 }} - whileHover={{ scale: 1.1 }} - whileTap={{ scale: 0.9 }} - > - - )} - - {/* Filter by status */} - - - - - - - - -
-
Filters
-
- - {uniqueStatusValues.map((value, i) => ( - - - handleStatusChange(checked, value) - } - /> - - - ))} - -
-
-
-
-
- {/* Toggle columns visibility */} - - - - - - - - - Toggle columns - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - return ( - column.toggleVisibility(!!value)} - onSelect={(event) => event.preventDefault()} - > - {column.id} - - ); - })} - - - -
-
- {/* Delete button */} - {table.getSelectedRowModel().rows.length > 0 && ( - - - - - -
- - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete{" "} - {table.getSelectedRowModel().rows.length} selected{" "} - {table.getSelectedRowModel().rows.length === 1 ? "row" : "rows"}. - - -
- - Cancel - Delete - -
-
- )} - {/* Add user button */} - {/* */} -
-
+ - {/* Table */} - - {loading ? ( -
-
-
-

Loading documents...

-
-
- ) : error ? ( -
-
- -

Error loading documents

- -
-
- ) : data.length === 0 ? ( -
-
- -

No documents found

-
-
- ) : ( - - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => { - return ( - - {header.isPlaceholder ? null : header.column.getCanSort() ? ( - - ) : ( - flexRender(header.column.columnDef.header, header.getContext()) - )} - - ); - })} - - ))} - - - - {table.getRowModel().rows?.length ? ( - table.getRowModel().rows.map((row, index) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - )) - ) : ( - - - No documents found. - - - )} - - -
- )} -
+ { + await (refreshDocuments?.() ?? Promise.resolve()); + }} + selectedIds={selectedIds} + setSelectedIds={setSelectedIds} + columnVisibility={columnVisibility} + deleteDocument={(id) => deleteDocument?.(id) ?? Promise.resolve(false)} + sortKey={sortKey} + sortDesc={sortDesc} + onSortChange={(key) => { + if (sortKey === key) setSortDesc((v) => !v); + else { + setSortKey(key); + setSortDesc(false); + } + }} + /> - {/* Pagination */} -
- {/* Results per page */} - - - - - {/* Page number information */} - -

- - {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1}- - {Math.min( - Math.max( - table.getState().pagination.pageIndex * table.getState().pagination.pageSize + - table.getState().pagination.pageSize, - 0 - ), - table.getRowCount() - )} - {" "} - of {table.getRowCount().toString()} -

-
- - {/* Pagination buttons */} -
- - - {/* First page button */} - - - - - - {/* Previous page button */} - - - - - - {/* Next page button */} - - - - - - {/* Last page button */} - - - - - - - -
-
-
-
- ); -} - -function RowActions({ row }: { row: Row }) { - const [isOpen, setIsOpen] = useState(false); - const [isDeleting, setIsDeleting] = useState(false); - const context = useContext(DocumentsContext); - - if (!context) { - throw new Error("DocumentsContext not found"); - } - - const { deleteDocument, refreshDocuments } = context; - const document = row.original; - - const handleDelete = async () => { - setIsDeleting(true); - try { - await deleteDocument(document.id); - toast.success("Document deleted successfully"); - await refreshDocuments(); - } catch (error) { - console.error("Error deleting document:", error); - toast.error("Failed to delete document"); - } finally { - setIsDeleting(false); - setIsOpen(false); - } - }; - - return ( -
- - - - - - e.preventDefault()}> - View Metadata - - } - /> - - - - { - e.preventDefault(); - setIsOpen(true); - }} - > - Delete - - - - - Are you sure? - - This action cannot be undone. This will permanently delete the document. - - - - Cancel - { - e.preventDefault(); - handleDelete(); - }} - disabled={isDeleting} - > - {isDeleting ? "Deleting..." : "Delete"} - - - - - - -
+ { + 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))} + canPrev={pageIndex > 0} + canNext={pageEnd < total} + id={id} + /> + ); }