diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx index 93e1cadbf..9733d9683 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/(manage)/components/DocumentsTableShell.tsx @@ -1,28 +1,45 @@ "use client"; -import { formatDistanceToNow } from "date-fns"; import { AlertCircle, - BadgeInfo, - Calendar, CheckCircle2, ChevronDown, ChevronUp, Clock, + Eye, FileText, FileX, Network, + PenLine, Plus, - User, + Trash2, } from "lucide-react"; import { motion } from "motion/react"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import React, { useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; -import { JsonMetadataViewer } from "@/components/json-metadata-viewer"; import { MarkdownViewer } from "@/components/markdown-viewer"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@/components/ui/context-menu"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; @@ -36,11 +53,12 @@ import { } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { documentsApiService } from "@/lib/apis/documents-api.service"; -import { DocumentTypeChip } from "./DocumentTypeIcon"; -import { RowActions } from "./RowActions"; +import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon"; import type { ColumnVisibility, Document, DocumentStatus } from "./types"; -// Status indicator component for document processing status +const EDITABLE_DOCUMENT_TYPES = ["FILE", "NOTE"] as const; +const NON_DELETABLE_DOCUMENT_TYPES = ["SURFSENSE_DOCS"] as const; + function StatusIndicator({ status }: { status?: DocumentStatus }) { const state = status?.state ?? "ready"; @@ -107,10 +125,6 @@ function sortDocuments(docs: Document[], key: SortKey, desc: boolean): Document[ return desc ? sorted.reverse() : sorted; } -function formatRelativeDate(dateStr: string): string { - return formatDistanceToNow(new Date(dateStr), { addSuffix: true }); -} - function formatAbsoluteDate(dateStr: string): string { const date = new Date(dateStr); return date.toLocaleString("en-US", { @@ -123,7 +137,13 @@ function formatAbsoluteDate(dateStr: string): string { }); } -function TruncatedText({ text, className }: { text: string; className?: string }) { +function DocumentNameTooltip({ + doc, + className, +}: { + doc: Document; + className?: string; +}) { const textRef = useRef(null); const [isTruncated, setIsTruncated] = useState(false); @@ -138,25 +158,33 @@ function TruncatedText({ text, className }: { text: string; className?: string } return () => window.removeEventListener("resize", checkTruncation); }, []); - if (isTruncated) { - return ( - - - - {text} - - - -

{text}

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

{doc.title}

+ )} +

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

+

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

+

+ Source:{" "} + {getDocumentTypeLabel(doc.document_type)} +

+
+
+
); } @@ -193,13 +221,78 @@ function SortableHeader({ ); } +function RowContextMenu({ + doc, + children, + onPreview, + onDelete, + searchSpaceId, +}: { + doc: Document; + children: React.ReactNode; + onPreview: (doc: Document) => void; + onDelete: (doc: Document) => void; + searchSpaceId: string; +}) { + const router = useRouter(); + + const isEditable = EDITABLE_DOCUMENT_TYPES.includes( + doc.document_type as (typeof EDITABLE_DOCUMENT_TYPES)[number] + ); + const isBeingProcessed = + doc.status?.state === "pending" || doc.status?.state === "processing"; + const isFileFailed = doc.document_type === "FILE" && doc.status?.state === "failed"; + const shouldShowDelete = !NON_DELETABLE_DOCUMENT_TYPES.includes( + doc.document_type as (typeof NON_DELETABLE_DOCUMENT_TYPES)[number] + ); + const isEditDisabled = isBeingProcessed || isFileFailed; + const isDeleteDisabled = isBeingProcessed; + + return ( + + {children} + + onPreview(doc)}> + + Preview + + {isEditable && ( + + !isEditDisabled && + router.push(`/dashboard/${searchSpaceId}/editor/${doc.id}`) + } + disabled={isEditDisabled} + > + + Edit + + )} + {shouldShowDelete && ( + <> + + !isDeleteDisabled && onDelete(doc)} + disabled={isDeleteDisabled} + > + + Delete + + + )} + + + ); +} + export function DocumentsTableShell({ documents, loading, error, selectedIds, setSelectedIds, - columnVisibility, + columnVisibility: _columnVisibility, sortKey, sortDesc, onSortChange, @@ -221,59 +314,19 @@ export function DocumentsTableShell({ const t = useTranslations("documents"); const { openDialog } = useDocumentUploadDialog(); - // State for metadata viewer (opened via Ctrl/Cmd+Click) - // Real-time documents don't sync metadata - we fetch on-demand when viewing - const [metadataDoc, setMetadataDoc] = useState(null); - const [metadataContent, setMetadataContent] = useState(null); - const [metadataLoading, setMetadataLoading] = useState(false); - - // State for lazy document content viewer - // Real-time documents don't sync content - we fetch on-demand when viewing const [viewingDoc, setViewingDoc] = useState(null); const [viewingContent, setViewingContent] = useState(""); const [viewingLoading, setViewingLoading] = useState(false); - // Fetch document metadata on-demand when metadata viewer is opened - const handleViewMetadata = useCallback(async (doc: Document) => { - setMetadataDoc(doc); + const [deleteDoc, setDeleteDoc] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); - // If metadata is already available (from API/search), use it directly - if (doc.document_metadata) { - setMetadataContent(doc.document_metadata); - return; - } - - // Otherwise, fetch from API (lazy loading for real-time synced documents) - setMetadataLoading(true); - try { - const fullDoc = await documentsApiService.getDocument({ id: doc.id }); - setMetadataContent(fullDoc.document_metadata); - } catch (err) { - console.error("[DocumentsTableShell] Failed to fetch document metadata:", err); - setMetadataContent(null); - } finally { - setMetadataLoading(false); - } - }, []); - - // Close metadata viewer - const handleCloseMetadata = useCallback(() => { - setMetadataDoc(null); - setMetadataContent(null); - setMetadataLoading(false); - }, []); - - // Fetch document content on-demand when viewer is opened const handleViewDocument = useCallback(async (doc: Document) => { setViewingDoc(doc); - - // If content is already available (from API/search), use it directly if (doc.content) { setViewingContent(doc.content); return; } - - // Otherwise, fetch from API (lazy loading for real-time synced documents) setViewingLoading(true); try { const fullDoc = await documentsApiService.getDocument({ id: doc.id }); @@ -286,25 +339,47 @@ export function DocumentsTableShell({ } }, []); - // Close document viewer const handleCloseViewer = useCallback(() => { setViewingDoc(null); setViewingContent(""); setViewingLoading(false); }, []); + const handleDeleteFromMenu = useCallback( + async () => { + if (!deleteDoc) return; + setIsDeleting(true); + try { + const ok = await deleteDocument(deleteDoc.id); + if (!ok) toast.error("Failed to delete document"); + } catch (error: unknown) { + console.error("Error deleting document:", error); + const status = + (error as { response?: { status?: number } })?.response?.status ?? + (error as { status?: number })?.status; + if (status === 409) { + toast.error("Document is now being processed. Please try again later."); + } else { + toast.error("Failed to delete document"); + } + } finally { + setIsDeleting(false); + setDeleteDoc(null); + } + }, + [deleteDoc, deleteDocument] + ); + const sorted = React.useMemo( () => sortDocuments(documents, sortKey, sortDesc), [documents, sortKey, sortDesc] ); - // Helper: check if document can be selected (not processing/pending) const isSelectable = (doc: Document) => { const state = doc.status?.state; return state !== "pending" && state !== "processing"; }; - // Only consider selectable documents for "select all" logic const selectableDocs = sorted.filter(isSelectable); const allSelectedOnPage = selectableDocs.length > 0 && selectableDocs.every((d) => selectedIds.has(d.id)); @@ -314,7 +389,6 @@ export function DocumentsTableShell({ const toggleAll = (checked: boolean) => { const next = new Set(selectedIds); if (checked) - // Only select documents that are not processing/pending selectableDocs.forEach((d) => { next.add(d.id); }); @@ -343,7 +417,7 @@ export function DocumentsTableShell({ > {loading ? ( <> - {/* Desktop Skeleton View */} + {/* Desktop Skeleton */}
@@ -353,31 +427,14 @@ export function DocumentsTableShell({ - + - {columnVisibility.document_type && ( - - - - )} - {columnVisibility.created_by && ( - - - - )} - {columnVisibility.created_at && ( - - - - )} - {columnVisibility.status && ( - - - - )} - - Actions + + + + + @@ -395,31 +452,14 @@ export function DocumentsTableShell({ - + - {columnVisibility.document_type && ( - - - - )} - {columnVisibility.created_by && ( - - - - )} - {columnVisibility.created_at && ( - - - - )} - {columnVisibility.status && ( - - - - )} - + + + + ))} @@ -427,23 +467,18 @@ export function DocumentsTableShell({
- {/* Mobile Skeleton View */} + {/* Mobile Skeleton */}
{[70, 85, 55, 78, 62, 90].map((widthPercent, index) => (
-
- -
+
+ +
-
- - {columnVisibility.created_by && } - {columnVisibility.created_at && } -
- {columnVisibility.status && } - + +
@@ -484,7 +519,6 @@ export function DocumentsTableShell({ <> {/* Desktop Table View */}
- {/* Fixed Header */} @@ -498,7 +532,7 @@ export function DocumentsTableShell({ /> - + - {columnVisibility.document_type && ( - - } - > - Source - - - )} - {columnVisibility.created_by && ( - - - - User - - - )} - {columnVisibility.created_at && ( - - } - > - Created - - - )} - {columnVisibility.status && ( - - - - Status - - - )} - - Actions + + + + + + + + Status +
- {/* Scrollable Body */}
{sorted.map((doc, index) => { - const title = doc.title; const isSelected = selectedIds.has(doc.id); const canSelect = isSelectable(doc); return ( - - -
- canSelect && toggleOne(doc.id, !!v)} - disabled={!canSelect} - aria-label={ - canSelect ? "Select row" : "Cannot select while processing" - } - className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canSelect ? "opacity-40 cursor-not-allowed" : ""}`} + + +
+ + canSelect && toggleOne(doc.id, !!v) + } + disabled={!canSelect} + aria-label={ + canSelect + ? "Select row" + : "Cannot select while processing" + } + className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canSelect ? "opacity-40 cursor-not-allowed" : ""}`} + /> +
+
+ + -
-
- - - - {columnVisibility.document_type && ( - - - )} - {columnVisibility.created_by && ( - - {doc.created_by_name ? ( - doc.created_by_email ? ( - - - - {doc.created_by_name} - - - - {doc.created_by_email} - - - ) : ( - {doc.created_by_name} - ) - ) : ( - {doc.created_by_email || "—"} - )} - - )} - {columnVisibility.created_at && ( - + - - {formatRelativeDate(doc.created_at)} + + {getDocumentTypeIcon( + doc.document_type, + "h-4 w-4" + )} - {formatAbsoluteDate(doc.created_at)} + {getDocumentTypeLabel(doc.document_type)} - )} - {columnVisibility.status && ( - + - )} - - - -
+ + ); })}
@@ -683,104 +635,72 @@ export function DocumentsTableShell({ - {/* Mobile Card View - Notion Style */} + {/* Mobile Card View */}
{sorted.map((doc, index) => { const isSelected = selectedIds.has(doc.id); const canSelect = isSelectable(doc); return ( - -
- canSelect && toggleOne(doc.id, !!v)} - disabled={!canSelect} - aria-label={canSelect ? "Select row" : "Cannot select while processing"} - className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary ${!canSelect ? "opacity-40 cursor-not-allowed" : ""}`} - /> -
- -
- - {columnVisibility.created_by && doc.created_by_name && ( - {doc.created_by_name} - )} - {columnVisibility.created_at && ( - - - - {formatRelativeDate(doc.created_at)} - - - - {formatAbsoluteDate(doc.created_at)} - - - )} + +
+ + canSelect && toggleOne(doc.id, !!v) + } + disabled={!canSelect} + aria-label={ + canSelect + ? "Select row" + : "Cannot select while processing" + } + className={`border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary shrink-0 ${!canSelect ? "opacity-40 cursor-not-allowed" : ""}`} + /> +
+ +
+
+ + + + {getDocumentTypeIcon( + doc.document_type, + "h-4 w-4" + )} + + + + {getDocumentTypeLabel(doc.document_type)} + + +
-
- {columnVisibility.status && } - -
-
- + + ); })}
)} - {/* Metadata Viewer - opened via Ctrl/Cmd+Click on document title */} - {/* Lazy loads metadata from API for real-time synced documents */} - { - if (!open) handleCloseMetadata(); - }} - /> - - {/* Document Content Viewer - lazy loads content on-demand */} + {/* Document Content Viewer */} !open && handleCloseViewer()}> @@ -797,6 +717,32 @@ export function DocumentsTableShell({
+ + {/* Delete Confirmation Dialog */} + !open && setDeleteDoc(null)}> + + + Delete document? + + This action cannot be undone. This will permanently delete this document from + your search space. + + + + Cancel + { + e.preventDefault(); + handleDeleteFromMenu(); + }} + disabled={isDeleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {isDeleting ? "Deleting" : "Delete"} + + + +
); } diff --git a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx index 9ffd97943..88a2a9e15 100644 --- a/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/DocumentsSidebar.tsx @@ -325,7 +325,7 @@ export function DocumentsSidebar({ open, onOpenChange }: DocumentsSidebarProps) open={open} onOpenChange={onOpenChange} ariaLabel={t("title") || "Documents"} - width={isMobile ? undefined : 720} + width={isMobile ? undefined : 480} > {documentsContent}