mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-08 20:25:19 +02:00
feat: enhance document filters and table components with search functionality and improved loading states
This commit is contained in:
parent
90f9fad95c
commit
878e829bdc
4 changed files with 480 additions and 267 deletions
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue