refactor: enhance DocumentsTableShell with context menu for document actions and improve document tooltip display

This commit is contained in:
Anish Sarkar 2026-03-05 22:02:45 +05:30
parent 4a5a38c25b
commit e75e5ca689
2 changed files with 309 additions and 363 deletions

View file

@ -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<HTMLSpanElement>(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 (
<Tooltip>
<TooltipTrigger asChild>
<span ref={textRef} className={className}>
{text}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="break-words">{text}</p>
</TooltipContent>
</Tooltip>
);
}
return (
<span ref={textRef} className={className}>
{text}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span ref={textRef} className={className}>
{doc.title}
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start" className="max-w-sm">
<div className="space-y-1 text-xs">
{isTruncated && (
<p className="font-medium text-sm break-words">{doc.title}</p>
)}
<p>
<span className="text-muted-foreground">Owner:</span>{" "}
{doc.created_by_name || doc.created_by_email || "—"}
</p>
<p>
<span className="text-muted-foreground">Created:</span>{" "}
{formatAbsoluteDate(doc.created_at)}
</p>
<p>
<span className="text-muted-foreground">Source:</span>{" "}
{getDocumentTypeLabel(doc.document_type)}
</p>
</div>
</TooltipContent>
</Tooltip>
);
}
@ -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 (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem onClick={() => onPreview(doc)}>
<Eye className="h-4 w-4" />
Preview
</ContextMenuItem>
{isEditable && (
<ContextMenuItem
onClick={() =>
!isEditDisabled &&
router.push(`/dashboard/${searchSpaceId}/editor/${doc.id}`)
}
disabled={isEditDisabled}
>
<PenLine className="h-4 w-4" />
Edit
</ContextMenuItem>
)}
{shouldShowDelete && (
<>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onClick={() => !isDeleteDisabled && onDelete(doc)}
disabled={isDeleteDisabled}
>
<Trash2 className="h-4 w-4" />
Delete
</ContextMenuItem>
</>
)}
</ContextMenuContent>
</ContextMenu>
);
}
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<Document | null>(null);
const [metadataContent, setMetadataContent] = useState<any>(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<Document | null>(null);
const [viewingContent, setViewingContent] = useState<string>("");
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<Document | null>(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 */}
<div className="hidden md:flex md:flex-col">
<Table className="table-fixed w-full">
<TableHeader>
@ -353,31 +427,14 @@ export function DocumentsTableShell({
<Skeleton className="h-4 w-4 rounded" />
</div>
</TableHead>
<TableHead className="w-[40%] max-w-0 border-r border-border/40">
<TableHead>
<Skeleton className="h-3 w-20" />
</TableHead>
{columnVisibility.document_type && (
<TableHead className="w-[15%] min-w-[100px] max-w-[170px] border-r border-border/40">
<Skeleton className="h-3 w-14" />
</TableHead>
)}
{columnVisibility.created_by && (
<TableHead className="w-36 border-r border-border/40">
<Skeleton className="h-3 w-10" />
</TableHead>
)}
{columnVisibility.created_at && (
<TableHead className="w-32 border-r border-border/40">
<Skeleton className="h-3 w-16" />
</TableHead>
)}
{columnVisibility.status && (
<TableHead className="w-14 text-center">
<Skeleton className="h-3 w-12 mx-auto" />
</TableHead>
)}
<TableHead className="w-10">
<span className="sr-only">Actions</span>
<TableHead className="w-10 text-center">
<Skeleton className="h-3 w-4 mx-auto" />
</TableHead>
<TableHead className="w-12 text-center">
<Skeleton className="h-3 w-8 mx-auto" />
</TableHead>
</TableRow>
</TableHeader>
@ -395,31 +452,14 @@ export function DocumentsTableShell({
<Skeleton className="h-4 w-4 rounded" />
</div>
</TableCell>
<TableCell className="w-[40%] py-2.5 max-w-0 border-r border-border/40">
<TableCell className="py-2.5 max-w-0">
<Skeleton className="h-4" style={{ width: `${widthPercent}%` }} />
</TableCell>
{columnVisibility.document_type && (
<TableCell className="w-[15%] min-w-[100px] max-w-[170px] py-2.5 border-r border-border/40 overflow-hidden">
<Skeleton className="h-5 w-24 rounded" />
</TableCell>
)}
{columnVisibility.created_by && (
<TableCell className="w-36 py-2.5 truncate border-r border-border/40">
<Skeleton className="h-4 w-20" />
</TableCell>
)}
{columnVisibility.created_at && (
<TableCell className="w-32 py-2.5 border-r border-border/40">
<Skeleton className="h-4 w-20" />
</TableCell>
)}
{columnVisibility.status && (
<TableCell className="w-14 py-2.5 text-center">
<Skeleton className="h-5 w-5 mx-auto rounded-full" />
</TableCell>
)}
<TableCell className="w-10 py-2.5 text-center">
<Skeleton className="h-6 w-6 mx-auto rounded" />
<Skeleton className="h-4 w-4 mx-auto rounded" />
</TableCell>
<TableCell className="w-12 py-2.5 text-center">
<Skeleton className="h-5 w-5 mx-auto rounded-full" />
</TableCell>
</TableRow>
))}
@ -427,23 +467,18 @@ export function DocumentsTableShell({
</Table>
</div>
</div>
{/* Mobile Skeleton View */}
{/* Mobile Skeleton */}
<div className="md:hidden divide-y divide-border/30 h-[50vh] overflow-auto">
{[70, 85, 55, 78, 62, 90].map((widthPercent, index) => (
<div key={`skeleton-mobile-${index}`} className="px-4 py-3">
<div className="flex items-start gap-3">
<Skeleton className="h-4 w-4 mt-0.5 rounded" />
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-3">
<Skeleton className="h-4 w-4 rounded shrink-0" />
<div className="flex-1 min-w-0">
<Skeleton className="h-4" style={{ width: `${widthPercent}%` }} />
<div className="flex flex-wrap items-center gap-2">
<Skeleton className="h-5 w-20 rounded" />
{columnVisibility.created_by && <Skeleton className="h-3 w-14" />}
{columnVisibility.created_at && <Skeleton className="h-3 w-20" />}
</div>
</div>
<div className="flex items-center gap-2">
{columnVisibility.status && <Skeleton className="h-5 w-5 rounded-full" />}
<Skeleton className="h-7 w-7 rounded" />
<Skeleton className="h-4 w-4 rounded shrink-0" />
<Skeleton className="h-5 w-5 rounded-full shrink-0" />
</div>
</div>
</div>
@ -484,7 +519,6 @@ export function DocumentsTableShell({
<>
{/* Desktop Table View */}
<div className="hidden md:flex md:flex-col">
{/* Fixed Header */}
<Table className="table-fixed w-full">
<TableHeader>
<TableRow className="hover:bg-transparent border-b border-border/40">
@ -498,7 +532,7 @@ export function DocumentsTableShell({
/>
</div>
</TableHead>
<TableHead className="w-[40%] border-r border-border/40">
<TableHead>
<SortableHeader
sortKey="title"
currentSortKey={sortKey}
@ -509,173 +543,91 @@ export function DocumentsTableShell({
Document
</SortableHeader>
</TableHead>
{columnVisibility.document_type && (
<TableHead className="w-[15%] min-w-[100px] max-w-[170px] border-r border-border/40">
<SortableHeader
sortKey="document_type"
currentSortKey={sortKey}
sortDesc={sortDesc}
onSort={onSortHeader}
icon={<Network size={14} className="text-muted-foreground" />}
>
Source
</SortableHeader>
</TableHead>
)}
{columnVisibility.created_by && (
<TableHead className="w-36 border-r border-border/40">
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
<User size={14} className="opacity-60 text-muted-foreground" />
User
</span>
</TableHead>
)}
{columnVisibility.created_at && (
<TableHead className="w-32 border-r border-border/40">
<SortableHeader
sortKey="created_at"
currentSortKey={sortKey}
sortDesc={sortDesc}
onSort={onSortHeader}
icon={<Calendar size={14} className="text-muted-foreground" />}
>
Created
</SortableHeader>
</TableHead>
)}
{columnVisibility.status && (
<TableHead className="w-14">
<span className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground/70">
<BadgeInfo size={14} className="opacity-60 text-muted-foreground" />
Status
</span>
</TableHead>
)}
<TableHead className="w-10">
<span className="sr-only">Actions</span>
<TableHead className="w-10 text-center">
<span className="flex items-center justify-center">
<Network size={14} className="text-muted-foreground/70" />
</span>
</TableHead>
<TableHead className="w-12 text-center">
<span className="text-xs font-medium text-muted-foreground/70">
Status
</span>
</TableHead>
</TableRow>
</TableHeader>
</Table>
{/* Scrollable Body */}
<div className="h-[50vh] overflow-auto">
<Table className="table-fixed w-full">
<TableBody>
{sorted.map((doc, index) => {
const title = doc.title;
const isSelected = selectedIds.has(doc.id);
const canSelect = isSelectable(doc);
return (
<motion.tr
<RowContextMenu
key={doc.id}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
duration: 0.2,
delay: index * 0.02,
},
}}
className={`border-b border-border/40 transition-colors ${
isSelected ? "bg-primary/5 hover:bg-primary/8" : "hover:bg-muted/30"
}`}
doc={doc}
onPreview={handleViewDocument}
onDelete={setDeleteDoc}
searchSpaceId={searchSpaceId}
>
<TableCell className="w-8 px-0 py-2.5 text-center">
<div className="flex items-center justify-center h-full">
<Checkbox
checked={isSelected}
onCheckedChange={(v) => 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" : ""}`}
<motion.tr
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
duration: 0.2,
delay: index * 0.02,
},
}}
className={`border-b border-border/40 transition-colors ${
isSelected
? "bg-primary/5 hover:bg-primary/8"
: "hover:bg-muted/30"
}`}
>
<TableCell className="w-8 px-0 py-2.5 text-center">
<div className="flex items-center justify-center h-full">
<Checkbox
checked={isSelected}
onCheckedChange={(v) =>
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" : ""}`}
/>
</div>
</TableCell>
<TableCell className="py-2.5 max-w-0">
<DocumentNameTooltip
doc={doc}
className="truncate block text-sm text-foreground cursor-default"
/>
</div>
</TableCell>
<TableCell className="w-[40%] py-2.5 max-w-0 border-r border-border/40">
<button
type="button"
className="block w-full text-left text-sm text-foreground hover:text-foreground transition-colors cursor-pointer bg-transparent border-0 p-0 truncate"
onClick={(e) => {
// Ctrl (Win/Linux) or Cmd (Mac) + Click opens metadata
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
handleViewMetadata(doc);
} else {
// Normal click opens document viewer (lazy loads content)
handleViewDocument(doc);
}
}}
onKeyDown={(e) => {
// Ctrl/Cmd + Enter opens metadata
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
handleViewMetadata(doc);
} else if (e.key === "Enter") {
// Enter opens document viewer
handleViewDocument(doc);
}
}}
>
<TruncatedText text={title} className="truncate block" />
</button>
</TableCell>
{columnVisibility.document_type && (
<TableCell className="w-[15%] min-w-[100px] max-w-[170px] py-2.5 border-r border-border/40 overflow-hidden">
<DocumentTypeChip type={doc.document_type} />
</TableCell>
)}
{columnVisibility.created_by && (
<TableCell className="w-36 py-2.5 text-sm text-foreground truncate border-r border-border/40">
{doc.created_by_name ? (
doc.created_by_email ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default truncate block">
{doc.created_by_name}
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start">
{doc.created_by_email}
</TooltipContent>
</Tooltip>
) : (
<span className="truncate block">{doc.created_by_name}</span>
)
) : (
<span className="truncate block">{doc.created_by_email || "—"}</span>
)}
</TableCell>
)}
{columnVisibility.created_at && (
<TableCell className="w-32 py-2.5 text-sm text-foreground border-r border-border/40">
<TableCell className="w-10 py-2.5 text-center">
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">
{formatRelativeDate(doc.created_at)}
<span className="flex items-center justify-center">
{getDocumentTypeIcon(
doc.document_type,
"h-4 w-4"
)}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{formatAbsoluteDate(doc.created_at)}
{getDocumentTypeLabel(doc.document_type)}
</TooltipContent>
</Tooltip>
</TableCell>
)}
{columnVisibility.status && (
<TableCell className="w-14 py-2.5 text-center">
<TableCell className="w-12 py-2.5 text-center">
<StatusIndicator status={doc.status} />
</TableCell>
)}
<TableCell className="w-10 py-2.5 text-center">
<RowActions
document={doc}
deleteDocument={deleteDocument}
searchSpaceId={searchSpaceId}
/>
</TableCell>
</motion.tr>
</motion.tr>
</RowContextMenu>
);
})}
</TableBody>
@ -683,104 +635,72 @@ export function DocumentsTableShell({
</div>
</div>
{/* Mobile Card View - Notion Style */}
{/* Mobile Card View */}
<div className="md:hidden divide-y divide-border/40 h-[50vh] overflow-auto">
{sorted.map((doc, index) => {
const isSelected = selectedIds.has(doc.id);
const canSelect = isSelectable(doc);
return (
<motion.div
<RowContextMenu
key={doc.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: index * 0.03 } }}
className={`px-4 py-3 transition-colors ${
isSelected ? "bg-primary/5" : "hover:bg-muted/20"
}`}
doc={doc}
onPreview={handleViewDocument}
onDelete={setDeleteDoc}
searchSpaceId={searchSpaceId}
>
<div className="flex items-center gap-3">
<Checkbox
checked={isSelected}
onCheckedChange={(v) => 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" : ""}`}
/>
<div className="flex-1 min-w-0 space-y-1.5">
<button
type="button"
className="text-left text-sm text-foreground hover:text-foreground transition-colors cursor-pointer truncate block w-full bg-transparent border-0 p-0"
onClick={(e) => {
// Ctrl (Win/Linux) or Cmd (Mac) + Click opens metadata
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
handleViewMetadata(doc);
} else {
// Normal click opens document viewer (lazy loads content)
handleViewDocument(doc);
}
}}
onKeyDown={(e) => {
// Ctrl/Cmd + Enter opens metadata
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
handleViewMetadata(doc);
} else if (e.key === "Enter") {
// Enter opens document viewer
handleViewDocument(doc);
}
}}
>
{doc.title}
</button>
<div className="flex flex-wrap items-center gap-2">
<DocumentTypeChip type={doc.document_type} />
{columnVisibility.created_by && doc.created_by_name && (
<span className="text-xs text-foreground">{doc.created_by_name}</span>
)}
{columnVisibility.created_at && (
<Tooltip>
<TooltipTrigger asChild>
<span className="text-xs text-foreground cursor-default">
{formatRelativeDate(doc.created_at)}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{formatAbsoluteDate(doc.created_at)}
</TooltipContent>
</Tooltip>
)}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: index * 0.03 } }}
className={`px-4 py-3 transition-colors ${
isSelected ? "bg-primary/5" : "hover:bg-muted/20"
}`}
>
<div className="flex items-center gap-3">
<Checkbox
checked={isSelected}
onCheckedChange={(v) =>
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" : ""}`}
/>
<div className="flex-1 min-w-0">
<DocumentNameTooltip
doc={doc}
className="truncate block text-sm text-foreground cursor-default"
/>
</div>
<div className="flex items-center gap-2 shrink-0">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center justify-center">
{getDocumentTypeIcon(
doc.document_type,
"h-4 w-4"
)}
</span>
</TooltipTrigger>
<TooltipContent side="top">
{getDocumentTypeLabel(doc.document_type)}
</TooltipContent>
</Tooltip>
<StatusIndicator status={doc.status} />
</div>
</div>
<div className="flex items-center gap-2">
{columnVisibility.status && <StatusIndicator status={doc.status} />}
<RowActions
document={doc}
deleteDocument={deleteDocument}
searchSpaceId={searchSpaceId}
/>
</div>
</div>
</motion.div>
</motion.div>
</RowContextMenu>
);
})}
</div>
</>
)}
{/* Metadata Viewer - opened via Ctrl/Cmd+Click on document title */}
{/* Lazy loads metadata from API for real-time synced documents */}
<JsonMetadataViewer
title={metadataDoc?.title ?? ""}
metadata={metadataContent}
loading={metadataLoading}
open={!!metadataDoc}
onOpenChange={(open) => {
if (!open) handleCloseMetadata();
}}
/>
{/* Document Content Viewer - lazy loads content on-demand */}
{/* Document Content Viewer */}
<Dialog open={!!viewingDoc} onOpenChange={(open) => !open && handleCloseViewer()}>
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col overflow-hidden pb-0">
<DialogHeader className="flex-shrink-0">
@ -797,6 +717,32 @@ export function DocumentsTableShell({
</div>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteDoc} onOpenChange={(open) => !open && setDeleteDoc(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete document?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete this document from
your search space.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleDeleteFromMenu();
}}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? "Deleting" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</motion.div>
);
}

View file

@ -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}
</SidebarSlideOutPanel>