feat: enhance document filters and table components with search functionality and improved loading states

This commit is contained in:
Anish Sarkar 2026-02-04 17:19:29 +05:30
parent 90f9fad95c
commit 878e829bdc
4 changed files with 480 additions and 267 deletions

View file

@ -4,8 +4,8 @@ import type React from "react";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export function getDocumentTypeIcon(type: string): React.ReactNode {
return getConnectorIcon(type);
export function getDocumentTypeIcon(type: string, className?: string): React.ReactNode {
return getConnectorIcon(type, className);
}
export function getDocumentTypeLabel(type: string): string {
@ -18,7 +18,7 @@ export function getDocumentTypeLabel(type: string): string {
const MAX_LABEL_LENGTH = 28;
export function DocumentTypeChip({ type, className }: { type: string; className?: string }) {
const icon = getDocumentTypeIcon(type);
const icon = getDocumentTypeIcon(type, "h-4 w-4");
const fullLabel = getDocumentTypeLabel(type);
const truncatedLabel = fullLabel.length > MAX_LABEL_LENGTH
? `${fullLabel.slice(0, MAX_LABEL_LENGTH)}...`
@ -27,9 +27,9 @@ export function DocumentTypeChip({ type, className }: { type: string; className?
const chip = (
<span
className={`inline-flex items-center gap-1.5 rounded-md border border-border/50 bg-muted/30 px-2 py-0.5 text-xs font-medium text-muted-foreground ${className ?? ""}`}
className={`inline-flex items-center gap-1.5 rounded bg-muted/40 px-2 py-1 text-xs text-muted-foreground ${className ?? ""}`}
>
<span className="opacity-70 flex-shrink-0">{icon}</span>
<span className="opacity-80 flex-shrink-0">{icon}</span>
<span className="truncate">{truncatedLabel}</span>
</span>
);

View file

@ -7,12 +7,14 @@ import {
Columns3,
FilePlus2,
FileType,
ListFilter,
Search,
SlidersHorizontal,
Trash,
} from "lucide-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl";
import React, { useMemo, useRef } from "react";
import React, { useMemo, useRef, useState } from "react";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import {
@ -64,10 +66,20 @@ export function DocumentsFilters({
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const [typeSearchQuery, setTypeSearchQuery] = useState("");
const uniqueTypes = useMemo(() => {
return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[];
}, [typeCountsRecord]);
const filteredTypes = useMemo(() => {
if (!typeSearchQuery.trim()) return uniqueTypes;
const query = typeSearchQuery.toLowerCase();
return uniqueTypes.filter((type) =>
getDocumentTypeLabel(type).toLowerCase().includes(query)
);
}, [uniqueTypes, typeSearchQuery]);
const typeCounts = useMemo(() => {
const map = new Map<string, number>();
for (const [type, count] of Object.entries(typeCountsRecord)) {
@ -117,10 +129,13 @@ export function DocumentsFilters({
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
>
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-muted-foreground">
<ListFilter size={14} aria-hidden="true" />
</div>
<Input
id={`${id}-input`}
ref={inputRef}
className="peer h-9 w-full pl-3 pr-9 text-sm bg-background border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30"
className="peer h-9 w-full pl-9 pr-9 text-sm bg-background border-border/60 focus-visible:ring-1 focus-visible:ring-ring/30"
value={searchValue}
onChange={(e) => onSearch(e.target.value)}
placeholder="Filter by title"
@ -148,74 +163,94 @@ export function DocumentsFilters({
{/* Filter Buttons Group */}
<div className="flex items-center gap-2 flex-wrap">
{/* Type Filter */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 gap-2 border-dashed border-border/60 text-muted-foreground hover:text-foreground hover:border-border"
>
<FileType size={14} className="text-muted-foreground" />
<span className="hidden sm:inline">Type</span>
{activeTypes.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
{activeTypes.length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
<div className="px-2.5 pt-3">
<div className="mb-1.5 px-1 text-[11px] font-medium text-muted-foreground">
Filter by source
{/* Type Filter */}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 gap-2 border-dashed border-border/60 text-muted-foreground hover:text-foreground hover:border-border"
>
<FileType size={14} className="text-muted-foreground" />
<span className="hidden sm:inline">Type</span>
{activeTypes.length > 0 && (
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
{activeTypes.length}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
<div>
{/* Search input */}
<div className="p-2 border-b border-border/50">
<div className="relative">
<Search className="absolute left-0.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search types..."
value={typeSearchQuery}
onChange={(e) => setTypeSearchQuery(e.target.value)}
className="h-6 pl-6 text-sm bg-transparent border-0 focus-visible:ring-0"
/>
</div>
<div className="space-y-0.5 max-h-[300px] overflow-y-auto overflow-x-hidden">
{uniqueTypes.map((value: DocumentTypeEnum, i) => (
</div>
<div className="max-h-[300px] overflow-y-auto overflow-x-hidden py-1.5 px-1.5">
{filteredTypes.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No types found
</div>
) : (
filteredTypes.map((value: DocumentTypeEnum, i) => (
<button
key={value}
type="button"
className="flex w-full items-center gap-2 py-1 px-2.5 rounded-md hover:bg-muted/50 transition-colors cursor-pointer text-left"
className="flex w-full items-center gap-2.5 py-2 px-3 rounded-md hover:bg-muted/50 transition-colors cursor-pointer text-left"
onClick={() => onToggleType(value, !activeTypes.includes(value))}
>
{/* Icon */}
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-muted/50 text-foreground/80">
{getDocumentTypeIcon(value, "h-4 w-4")}
</div>
{/* Text content */}
<div className="flex flex-col min-w-0 flex-1 gap-0.5">
<span className="text-[13px] font-medium text-foreground truncate leading-tight">
{getDocumentTypeLabel(value)}
</span>
<span className="text-[11px] text-muted-foreground leading-tight">
{typeCounts.get(value)} document{(typeCounts.get(value) ?? 0) !== 1 ? "s" : ""}
</span>
</div>
{/* Checkbox */}
<Checkbox
id={`${id}-${i}`}
checked={activeTypes.includes(value)}
onCheckedChange={(checked: boolean) => onToggleType(value, !!checked)}
className="h-3.5 w-3.5 flex-shrink-0 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
className="h-4 w-4 shrink-0 rounded border-muted-foreground/30 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<Label
htmlFor={`${id}-${i}`}
className="flex flex-1 items-center gap-2 font-normal text-xs cursor-pointer min-w-0"
>
<span className="opacity-60 flex-shrink-0">{getDocumentTypeIcon(value)}</span>
<span className="truncate min-w-0">{getDocumentTypeLabel(value)}</span>
<span className="text-[10px] text-muted-foreground/70 tabular-nums flex-shrink-0 ml-auto">
{typeCounts.get(value)}
</span>
</Label>
</button>
))}
</div>
{activeTypes.length > 0 && (
<div className="mt-1 pt-1 pb-1 border-t border-border/50 pb-1">
<Button
variant="ghost"
size="sm"
className="w-full h-6 text-[11px]"
onClick={() => {
activeTypes.forEach((t) => {
onToggleType(t, false);
});
}}
>
Clear filters
</Button>
</div>
))
)}
</div>
</PopoverContent>
</Popover>
{activeTypes.length > 0 && (
<div className="px-3 pt-1.5 border-t border-border/50">
<Button
variant="ghost"
size="sm"
className="w-full h-7 text-[11px] text-muted-foreground hover:text-foreground"
onClick={() => {
activeTypes.forEach((t) => {
onToggleType(t, false);
});
}}
>
Clear filters
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
{/* View/Columns Popover */}
<Popover>
@ -266,57 +301,69 @@ export function DocumentsFilters({
</div>
</PopoverContent>
</Popover>
</div>
{/* Bulk Delete Button */}
{selectedIds.size > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
>
<Button
variant="destructive"
size="sm"
className="h-9 gap-2"
{/* Bulk Delete Button - positioned next to View on mobile */}
{selectedIds.size > 0 && (
<AlertDialog>
<AlertDialogTrigger asChild>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
>
<Trash size={14} />
Delete
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive-foreground/20 text-[10px] font-medium">
{selectedIds.size}
</span>
</Button>
</motion.div>
</AlertDialogTrigger>
<AlertDialogContent className="max-w-md">
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive"
aria-hidden="true"
>
<CircleAlert size={18} strokeWidth={2} />
{/* Mobile: icon with count */}
<Button
variant="destructive"
size="sm"
className="h-9 gap-1.5 px-2.5 md:hidden"
>
<Trash size={14} />
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive-foreground/20 text-[10px] font-medium">
{selectedIds.size}
</span>
</Button>
{/* Desktop: full button */}
<Button
variant="destructive"
size="sm"
className="h-9 gap-2 hidden md:flex"
>
<Trash size={14} />
Delete
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-destructive-foreground/20 text-[10px] font-medium">
{selectedIds.size}
</span>
</Button>
</motion.div>
</AlertDialogTrigger>
<AlertDialogContent className="max-w-md">
<div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
<div
className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive"
aria-hidden="true"
>
<CircleAlert size={18} strokeWidth={2} />
</div>
<AlertDialogHeader className="flex-1">
<AlertDialogTitle>Delete {selectedIds.size} document{selectedIds.size !== 1 ? "s" : ""}?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the selected {selectedIds.size === 1 ? "document" : "documents"} from your search space.
</AlertDialogDescription>
</AlertDialogHeader>
</div>
<AlertDialogHeader className="flex-1">
<AlertDialogTitle>Delete {selectedIds.size} document{selectedIds.size !== 1 ? "s" : ""}?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the selected {selectedIds.size === 1 ? "document" : "documents"} from your search space.
</AlertDialogDescription>
</AlertDialogHeader>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onBulkDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onBulkDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>
</div>
</motion.div>
);

View file

@ -1,16 +1,17 @@
"use client";
import { ChevronDown, ChevronUp, FileX, Plus } from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { Calendar, ChevronDown, ChevronUp, FileText, FileX, Link2, Plus, User } from "lucide-react";
import { motion } from "motion/react";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import React, { useState } from "react";
import React, { useRef, useState, useEffect } from "react";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { DocumentViewer } from "@/components/document-viewer";
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Spinner } from "@/components/ui/spinner";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
@ -37,35 +38,82 @@ function sortDocuments(docs: Document[], key: SortKey, desc: boolean): Document[
return desc ? sorted.reverse() : sorted;
}
function formatDate(dateStr: string): string {
function formatRelativeDate(dateStr: string): string {
return formatDistanceToNow(new Date(dateStr), { addSuffix: true });
}
function formatAbsoluteDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString("en-US", {
return date.toLocaleString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
}
function TruncatedText({ text, className }: { text: string; className?: string }) {
const textRef = useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = useState(false);
useEffect(() => {
const checkTruncation = () => {
if (textRef.current) {
setIsTruncated(textRef.current.scrollWidth > textRef.current.clientWidth);
}
};
checkTruncation();
window.addEventListener("resize", checkTruncation);
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>
);
}
function SortableHeader({
children,
sortKey,
currentSortKey,
sortDesc,
onSort,
icon,
}: {
children: React.ReactNode;
sortKey: SortKey;
currentSortKey: SortKey;
sortDesc: boolean;
onSort: (key: SortKey) => void;
icon?: React.ReactNode;
}) {
const isActive = currentSortKey === sortKey;
return (
<button
type="button"
onClick={() => onSort(sortKey)}
className="flex items-center gap-1.5 text-left font-medium text-muted-foreground hover:text-foreground transition-colors group"
className="flex items-center gap-1.5 text-left text-sm font-medium text-muted-foreground/70 hover:text-muted-foreground transition-colors group"
>
{icon && <span className="opacity-60">{icon}</span>}
{children}
<span className={`transition-opacity ${isActive ? "opacity-100" : "opacity-0 group-hover:opacity-50"}`}>
{isActive && sortDesc ? (
@ -143,18 +191,119 @@ export function DocumentsTableShell({
return (
<motion.div
className="rounded-xl border border-border/50 bg-card/30 backdrop-blur-sm overflow-hidden shadow-sm"
className="rounded-lg border border-border/30 bg-background overflow-hidden"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.2 }}
>
{loading ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Spinner size="lg" className="text-primary" />
<p className="text-sm text-muted-foreground">{t("loading")}</p>
<>
{/* Desktop Skeleton View */}
<div className="hidden md:flex md:flex-col">
<Table className="table-fixed w-full">
<TableHeader>
<TableRow className="hover:bg-transparent border-b border-border/30">
<TableHead className="w-8 px-0 text-center border-r border-border/30">
<div className="flex items-center justify-center h-full">
<Skeleton className="h-4 w-4 rounded" />
</div>
</TableHead>
<TableHead className="w-[35%] max-w-0 border-r border-border/30">
<Skeleton className="h-3 w-20" />
</TableHead>
{columnVisibility.document_type && (
<TableHead className="w-44 border-r border-border/30">
<Skeleton className="h-3 w-14" />
</TableHead>
)}
{columnVisibility.created_by && (
<TableHead className="w-36 border-r border-border/30">
<Skeleton className="h-3 w-10" />
</TableHead>
)}
{columnVisibility.created_at && (
<TableHead className="w-32 border-r border-border/30">
<Skeleton className="h-3 w-16" />
</TableHead>
)}
<TableHead className="w-10 text-center">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
</Table>
<div className="h-[50vh] overflow-auto">
<Table className="table-fixed w-full">
<TableBody>
{[65, 80, 45, 72, 55, 88, 40, 60, 50, 75].map((widthPercent, index) => (
<TableRow
key={`skeleton-${index}`}
className="border-b border-border/30 hover:bg-transparent"
>
<TableCell className="w-8 px-0 py-2.5 text-center border-r border-border/30">
<div className="flex items-center justify-center h-full">
<Skeleton className="h-4 w-4 rounded" />
</div>
</TableCell>
<TableCell className="w-[35%] py-2.5 max-w-0 border-r border-border/30">
<Skeleton
className="h-4"
style={{ width: `${widthPercent}%` }}
/>
</TableCell>
{columnVisibility.document_type && (
<TableCell className="w-44 py-2.5 border-r border-border/30">
<Skeleton className="h-5 w-24 rounded" />
</TableCell>
)}
{columnVisibility.created_by && (
<TableCell className="w-36 py-2.5 truncate border-r border-border/30">
<Skeleton className="h-4 w-20" />
</TableCell>
)}
{columnVisibility.created_at && (
<TableCell className="w-32 py-2.5 border-r border-border/30">
<Skeleton className="h-4 w-20" />
</TableCell>
)}
<TableCell className="w-10 py-2.5 px-0">
<div className="flex justify-center">
<Skeleton className="h-7 w-7 rounded" />
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
{/* Mobile Skeleton View */}
<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">
<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>
<Skeleton className="h-7 w-7 rounded" />
</div>
</div>
))}
</div>
</>
) : error ? (
<div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-3">
@ -189,72 +338,79 @@ export function DocumentsTableShell({
</div>
) : (
<>
{/* Desktop Table View */}
{/* Desktop Table View - Notion Style */}
<div className="hidden md:flex md:flex-col">
{/* Fixed Header */}
<Table>
<Table className="table-fixed w-full">
<TableHeader>
<TableRow className="bg-muted/30 hover:bg-muted/30 border-b border-border/50">
<TableHead className="w-[40px] pl-4">
<Checkbox
checked={allSelectedOnPage || (someSelectedOnPage && "indeterminate")}
onCheckedChange={(v) => toggleAll(!!v)}
aria-label="Select all"
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<TableRow className="hover:bg-transparent border-b border-border/30">
<TableHead className="w-8 px-0 text-center border-r border-border/30">
<div className="flex items-center justify-center h-full">
<Checkbox
checked={allSelectedOnPage || (someSelectedOnPage && "indeterminate")}
onCheckedChange={(v) => toggleAll(!!v)}
aria-label="Select all"
className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
</div>
</TableHead>
<TableHead className="min-w-[200px]">
<TableHead className="w-[35%] border-r border-border/30">
<SortableHeader
sortKey="title"
currentSortKey={sortKey}
sortDesc={sortDesc}
onSort={onSortHeader}
icon={<FileText size={14} className="text-muted-foreground" />}
>
Document
</SortableHeader>
</TableHead>
{columnVisibility.document_type && (
<TableHead className="w-[160px]">
<TableHead className="w-44 border-r border-border/30">
<SortableHeader
sortKey="document_type"
currentSortKey={sortKey}
sortDesc={sortDesc}
onSort={onSortHeader}
icon={<Link2 size={14} className="text-muted-foreground" />}
>
Source
</SortableHeader>
</TableHead>
)}
{columnVisibility.created_by && (
<TableHead className="w-[150px]">
<span className="text-muted-foreground font-medium">User</span>
<TableHead className="w-36 border-r border-border/30">
<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-[150px]">
<TableHead className="w-32 border-r border-border/30">
<SortableHeader
sortKey="created_at"
currentSortKey={sortKey}
sortDesc={sortDesc}
onSort={onSortHeader}
icon={<Calendar size={14} className="text-muted-foreground" />}
>
Created
</SortableHeader>
</TableHead>
)}
<TableHead className="w-[80px] pr-4">
<TableHead className="w-10 text-center">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
</Table>
{/* Scrollable Body */}
<div className="max-h-[55vh] overflow-auto">
<Table>
<div className="h-[50vh] overflow-auto">
<Table className="table-fixed w-full">
<TableBody>
{sorted.map((doc, index) => {
const title = doc.title;
const truncatedTitle = title.length > 50 ? `${title.slice(0, 50)}...` : title;
const isSelected = selectedIds.has(doc.id);
return (
<motion.tr
@ -269,26 +425,28 @@ export function DocumentsTableShell({
}}
className={`border-b border-border/30 transition-colors ${
isSelected
? "bg-primary/5 hover:bg-primary/10"
: "hover:bg-muted/40"
? "bg-primary/5 hover:bg-primary/8"
: "hover:bg-muted/30"
}`}
>
<TableCell className="w-[40px] pl-4 py-3">
<Checkbox
checked={isSelected}
onCheckedChange={(v) => toggleOne(doc.id, !!v)}
aria-label="Select row"
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<TableCell className="w-8 px-0 py-2.5 text-center border-r border-border/30">
<div className="flex items-center justify-center h-full">
<Checkbox
checked={isSelected}
onCheckedChange={(v) => toggleOne(doc.id, !!v)}
aria-label="Select row"
className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
</div>
</TableCell>
<TableCell className="min-w-[200px] py-3">
<TableCell className="w-[35%] py-2.5 max-w-0 border-r border-border/30">
<DocumentViewer
title={doc.title}
content={doc.content}
trigger={
<button
type="button"
className="text-left font-medium text-foreground/90 hover:text-primary transition-colors cursor-pointer bg-transparent border-0 p-0"
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) {
@ -305,46 +463,44 @@ export function DocumentsTableShell({
}
}}
>
{title.length > 50 ? (
<Tooltip>
<TooltipTrigger asChild>
<span>{truncatedTitle}</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="break-words">{title}</p>
</TooltipContent>
</Tooltip>
) : (
title
)}
<TruncatedText text={title} className="truncate block" />
</button>
}
/>
</TableCell>
{columnVisibility.document_type && (
<TableCell className="w-[160px] py-3">
<TableCell className="w-44 py-2.5 border-r border-border/30">
<DocumentTypeChip type={doc.document_type} />
</TableCell>
)}
{columnVisibility.created_by && (
<TableCell className="w-[150px] py-3 text-sm text-muted-foreground truncate">
<TableCell className="w-36 py-2.5 text-sm text-foreground truncate border-r border-border/30">
{doc.created_by_name || "—"}
</TableCell>
)}
{columnVisibility.created_at && (
<TableCell className="w-[150px] py-3 text-sm text-muted-foreground">
{formatDate(doc.created_at)}
<TableCell className="w-32 py-2.5 text-sm text-foreground border-r border-border/30">
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">{formatRelativeDate(doc.created_at)}</span>
</TooltipTrigger>
<TooltipContent side="top">
{formatAbsoluteDate(doc.created_at)}
</TooltipContent>
</Tooltip>
</TableCell>
)}
<TableCell className="w-[80px] pr-4 py-3">
<RowActions
document={doc}
deleteDocument={deleteDocument}
refreshDocuments={async () => {
await onRefresh();
}}
searchSpaceId={searchSpaceId as string}
/>
<TableCell className="w-10 py-2.5 px-0">
<div className="flex justify-center">
<RowActions
document={doc}
deleteDocument={deleteDocument}
refreshDocuments={async () => {
await onRefresh();
}}
searchSpaceId={searchSpaceId as string}
/>
</div>
</TableCell>
</motion.tr>
);
@ -354,8 +510,8 @@ export function DocumentsTableShell({
</div>
</div>
{/* Mobile Card View */}
<div className="md:hidden divide-y divide-border/30">
{/* Mobile Card View - Notion Style */}
<div className="md:hidden divide-y divide-border/30 h-[50vh] overflow-auto">
{sorted.map((doc, index) => {
const isSelected = selectedIds.has(doc.id);
return (
@ -363,25 +519,25 @@ export function DocumentsTableShell({
key={doc.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: index * 0.03 } }}
className={`p-4 transition-colors ${
isSelected ? "bg-primary/5" : "hover:bg-muted/30"
className={`px-4 py-3 transition-colors ${
isSelected ? "bg-primary/5" : "hover:bg-muted/20"
}`}
>
<div className="flex items-start gap-3">
<div className="flex items-center gap-3">
<Checkbox
checked={isSelected}
onCheckedChange={(v) => toggleOne(doc.id, !!v)}
aria-label="Select row"
className="mt-0.5 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
className="border-foreground data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex-1 min-w-0 space-y-1.5">
<DocumentViewer
title={doc.title}
content={doc.content}
trigger={
<button
type="button"
className="text-left font-medium text-sm text-foreground/90 hover:text-primary transition-colors cursor-pointer truncate block w-full bg-transparent border-0 p-0"
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) {
@ -405,14 +561,21 @@ export function DocumentsTableShell({
<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-muted-foreground">
<span className="text-xs text-foreground">
{doc.created_by_name}
</span>
)}
{columnVisibility.created_at && (
<span className="text-xs text-muted-foreground">
{formatDate(doc.created_at)}
</span>
<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>
)}
</div>
</div>

View file

@ -1,7 +1,6 @@
"use client";
import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
@ -21,7 +20,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Document } from "./types";
// Only FILE and NOTE document types can be edited
@ -74,88 +72,93 @@ export function RowActions({
};
return (
<div className="flex items-center justify-end gap-1">
<>
{/* Desktop Actions */}
<div className="hidden md:flex items-center gap-1">
{isEditable && (
<Tooltip>
<TooltipTrigger asChild>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted/80"
onClick={handleEdit}
>
<Pencil className="h-4 w-4" />
<span className="sr-only">Edit Document</span>
</Button>
</motion.div>
</TooltipTrigger>
<TooltipContent side="top">
<p>Edit Document</p>
</TooltipContent>
</Tooltip>
)}
{isDeletable && (
<Tooltip>
<TooltipTrigger asChild>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
transition={{ type: "spring", stiffness: 400, damping: 17 }}
>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setIsDeleteOpen(true)}
disabled={isDeleting}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</motion.div>
</TooltipTrigger>
<TooltipContent side="top">
<p>Delete</p>
</TooltipContent>
</Tooltip>
)}
</div>
{/* Mobile Actions Dropdown */}
<div className="flex md:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{isEditable && (
<div className="hidden md:inline-flex items-center justify-center">
{isEditable ? (
// Editable documents: show 3-dot dropdown with edit + delete
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground hover:bg-muted/80">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="mr-2 h-4 w-4" />
<span>Edit</span>
</DropdownMenuItem>
)}
{isDeletable && (
<DropdownMenuItem
onClick={() => setIsDeleteOpen(true)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete</span>
{isDeletable && (
<DropdownMenuItem
onClick={() => setIsDeleteOpen(true)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
// Non-editable documents: show only delete button directly
isDeletable && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setIsDeleteOpen(true)}
disabled={isDeleting}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
)
)}
</div>
{/* Mobile Actions Dropdown */}
<div className="inline-flex md:hidden items-center justify-center">
{isEditable ? (
// Editable documents: show 3-dot dropdown
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={handleEdit}>
<Pencil className="mr-2 h-4 w-4" />
<span>Edit</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{isDeletable && (
<DropdownMenuItem
onClick={() => setIsDeleteOpen(true)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Delete</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : (
// Non-editable documents: show only delete button directly
isDeletable && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
onClick={() => setIsDeleteOpen(true)}
disabled={isDeleting}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
)
)}
</div>
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
@ -178,6 +181,6 @@ export function RowActions({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
);
}