mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-23 19:05:16 +02:00
refactor: enhance DocumentsTableShell with context menu for document actions and improve document tooltip display
This commit is contained in:
parent
4a5a38c25b
commit
e75e5ca689
2 changed files with 309 additions and 363 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue