mirror of
https://github.com/MODSetter/SurfSense.git
synced 2025-09-01 18:19:08 +00:00
Refactored document page: decomposed into sub-components, replaced tanstack table with shadcn table, updated layout for mobile
This commit is contained in:
parent
63ef9313f2
commit
658410d9e2
7 changed files with 1113 additions and 1030 deletions
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
IconBook,
|
||||
IconBrandDiscord,
|
||||
IconBrandGithub,
|
||||
IconBrandNotion,
|
||||
IconBrandSlack,
|
||||
IconBrandYoutube,
|
||||
IconCalendar,
|
||||
IconChecklist,
|
||||
IconLayoutKanban,
|
||||
IconTicket,
|
||||
} from "@tabler/icons-react";
|
||||
import { File, Globe, Webhook } from "lucide-react";
|
||||
import type React from "react";
|
||||
|
||||
type IconComponent = React.ComponentType<{ size?: number; className?: string }>;
|
||||
|
||||
const documentTypeIcons: Record<string, IconComponent> = {
|
||||
EXTENSION: Webhook,
|
||||
CRAWLED_URL: Globe,
|
||||
SLACK_CONNECTOR: IconBrandSlack,
|
||||
NOTION_CONNECTOR: IconBrandNotion,
|
||||
FILE: File,
|
||||
YOUTUBE_VIDEO: IconBrandYoutube,
|
||||
GITHUB_CONNECTOR: IconBrandGithub,
|
||||
LINEAR_CONNECTOR: IconLayoutKanban,
|
||||
JIRA_CONNECTOR: IconTicket,
|
||||
DISCORD_CONNECTOR: IconBrandDiscord,
|
||||
CONFLUENCE_CONNECTOR: IconBook,
|
||||
CLICKUP_CONNECTOR: IconChecklist,
|
||||
GOOGLE_CALENDAR_CONNECTOR: IconCalendar,
|
||||
};
|
||||
|
||||
export function getDocumentTypeIcon(type: string): IconComponent {
|
||||
return documentTypeIcons[type] ?? File;
|
||||
}
|
||||
|
||||
export function getDocumentTypeLabel(type: string): string {
|
||||
return type
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0) + word.slice(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function DocumentTypeChip({ type, className }: { type: string; className?: string }) {
|
||||
const Icon = getDocumentTypeIcon(type);
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
"inline-flex items-center gap-1.5 rounded-full border border-border bg-primary/5 px-2 py-1 text-xs font-medium " +
|
||||
(className ?? "")
|
||||
}
|
||||
>
|
||||
<Icon size={14} className="text-primary" />
|
||||
{getDocumentTypeLabel(type)}
|
||||
</span>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,276 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, type Variants } from "framer-motion";
|
||||
import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react";
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import type { ColumnVisibility, Document } from "./types";
|
||||
|
||||
const fadeInScale: Variants = {
|
||||
hidden: { opacity: 0, scale: 0.95 },
|
||||
visible: { opacity: 1, scale: 1, transition: { type: "spring", stiffness: 300, damping: 30 } },
|
||||
exit: { opacity: 0, scale: 0.95, transition: { duration: 0.15 } },
|
||||
};
|
||||
|
||||
export function DocumentsFilters({
|
||||
allDocuments,
|
||||
visibleDocuments: _visibleDocuments,
|
||||
selectedIds,
|
||||
onSearch,
|
||||
searchValue,
|
||||
onBulkDelete,
|
||||
onToggleType,
|
||||
activeTypes,
|
||||
columnVisibility,
|
||||
onToggleColumn,
|
||||
}: {
|
||||
allDocuments: Document[];
|
||||
visibleDocuments: Document[];
|
||||
selectedIds: Set<number>;
|
||||
onSearch: (v: string) => void;
|
||||
searchValue: string;
|
||||
onBulkDelete: () => Promise<void>;
|
||||
onToggleType: (type: string, checked: boolean) => void;
|
||||
activeTypes: string[];
|
||||
columnVisibility: ColumnVisibility;
|
||||
onToggleColumn: (id: keyof ColumnVisibility, checked: boolean) => void;
|
||||
}) {
|
||||
const id = React.useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const uniqueTypes = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const d of allDocuments) set.add(d.document_type);
|
||||
return Array.from(set).sort();
|
||||
}, [allDocuments]);
|
||||
|
||||
const typeCounts = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const d of allDocuments) map.set(d.document_type, (map.get(d.document_type) ?? 0) + 1);
|
||||
return map;
|
||||
}, [allDocuments]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex flex-wrap items-center justify-between gap-3"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.1 }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<motion.div
|
||||
className="relative"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
>
|
||||
<Input
|
||||
id={`${id}-input`}
|
||||
ref={inputRef}
|
||||
className="peer min-w-60 ps-9"
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearch(e.target.value)}
|
||||
placeholder="Filter by title..."
|
||||
type="text"
|
||||
aria-label="Filter by title"
|
||||
/>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50"
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<ListFilter size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</motion.div>
|
||||
{Boolean(searchValue) && (
|
||||
<motion.button
|
||||
className="absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-lg text-muted-foreground/80 outline-offset-2 transition-colors hover:text-foreground focus:z-10 focus-visible:outline focus-visible:outline-ring/70"
|
||||
aria-label="Clear filter"
|
||||
onClick={() => {
|
||||
onSearch("");
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
initial={{ opacity: 0, rotate: -90 }}
|
||||
animate={{ opacity: 1, rotate: 0 }}
|
||||
exit={{ opacity: 0, rotate: 90 }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<CircleX size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</motion.button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button variant="outline">
|
||||
<Filter
|
||||
className="-ms-1 me-2 opacity-60"
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Type
|
||||
{activeTypes.length > 0 && (
|
||||
<motion.span
|
||||
initial={{ scale: 0.8 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="-me-1 ms-3 inline-flex h-5 max-h-full items-center rounded border border-border bg-background px-1 text-[0.625rem] font-medium text-muted-foreground/70"
|
||||
>
|
||||
{activeTypes.length}
|
||||
</motion.span>
|
||||
)}
|
||||
</Button>
|
||||
</motion.div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-36 p-3" align="start">
|
||||
<motion.div initial="hidden" animate="visible" exit="exit" variants={fadeInScale}>
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs font-medium text-muted-foreground">Filters</div>
|
||||
<div className="space-y-3">
|
||||
<AnimatePresence>
|
||||
{uniqueTypes.map((value, i) => (
|
||||
<motion.div
|
||||
key={value}
|
||||
className="flex items-center gap-2"
|
||||
initial={{ opacity: 0, y: -5 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 5 }}
|
||||
transition={{ delay: i * 0.05 }}
|
||||
>
|
||||
<Checkbox
|
||||
id={`${id}-${i}`}
|
||||
checked={activeTypes.includes(value)}
|
||||
onCheckedChange={(checked: boolean) => onToggleType(value, !!checked)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${id}-${i}`}
|
||||
className="flex grow justify-between gap-2 font-normal"
|
||||
>
|
||||
{value}{" "}
|
||||
<span className="ms-2 text-xs text-muted-foreground">
|
||||
{typeCounts.get(value)}
|
||||
</span>
|
||||
</Label>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button variant="outline">
|
||||
<Columns3
|
||||
className="-ms-1 me-2 opacity-60"
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
View
|
||||
</Button>
|
||||
</motion.div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||
{(
|
||||
[
|
||||
["title", "Title"],
|
||||
["document_type", "Type"],
|
||||
["content", "Content"],
|
||||
["created_at", "Created At"],
|
||||
] as Array<[keyof ColumnVisibility, string]>
|
||||
).map(([key, label]) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={key}
|
||||
className="capitalize"
|
||||
checked={columnVisibility[key]}
|
||||
onCheckedChange={(v) => onToggleColumn(key, !!v)}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{label}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{selectedIds.size > 0 && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button className="ml-auto" variant="outline">
|
||||
<Trash
|
||||
className="-ms-1 me-2 opacity-60"
|
||||
size={16}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Delete
|
||||
<span className="-me-1 ms-3 inline-flex h-5 max-h-full items-center rounded border border-border bg-background px-1 text-[0.625rem] font-medium text-muted-foreground/70">
|
||||
{selectedIds.size}
|
||||
</span>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 max-sm:items-center sm:flex-row sm:gap-4">
|
||||
<div
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-border"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<CircleAlert className="opacity-80" size={16} strokeWidth={2} />
|
||||
</div>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete {selectedIds.size}{" "}
|
||||
selected {selectedIds.size === 1 ? "row" : "rows"}.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onBulkDelete}>Delete</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,355 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronDown, ChevronUp, FileX } from "lucide-react";
|
||||
import React from "react";
|
||||
import { DocumentViewer } from "@/components/document-viewer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { DocumentTypeChip, getDocumentTypeIcon } from "./DocumentTypeIcon";
|
||||
import { RowActions } from "./RowActions";
|
||||
import type { ColumnVisibility, Document } from "./types";
|
||||
|
||||
export type SortKey = keyof Pick<Document, "title" | "document_type" | "created_at">;
|
||||
|
||||
function sortDocuments(docs: Document[], key: SortKey, desc: boolean): Document[] {
|
||||
const sorted = [...docs].sort((a, b) => {
|
||||
const av = a[key] ?? "";
|
||||
const bv = b[key] ?? "";
|
||||
if (key === "created_at")
|
||||
return new Date(av as string).getTime() - new Date(bv as string).getTime();
|
||||
return String(av).localeCompare(String(bv));
|
||||
});
|
||||
return desc ? sorted.reverse() : sorted;
|
||||
}
|
||||
|
||||
function truncate(text: string, len = 150): string {
|
||||
const plain = text
|
||||
.replace(/[#*_`>\-[\]()]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (plain.length <= len) return plain;
|
||||
return `${plain.slice(0, len)}...`;
|
||||
}
|
||||
|
||||
export function DocumentsTableShell({
|
||||
documents,
|
||||
loading,
|
||||
error,
|
||||
onRefresh,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
columnVisibility,
|
||||
deleteDocument,
|
||||
sortKey,
|
||||
sortDesc,
|
||||
onSortChange,
|
||||
}: {
|
||||
documents: Document[];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
selectedIds: Set<number>;
|
||||
setSelectedIds: (update: Set<number>) => void;
|
||||
columnVisibility: ColumnVisibility;
|
||||
deleteDocument: (id: number) => Promise<boolean>;
|
||||
sortKey: SortKey;
|
||||
sortDesc: boolean;
|
||||
onSortChange: (key: SortKey) => void;
|
||||
}) {
|
||||
const sorted = React.useMemo(
|
||||
() => sortDocuments(documents, sortKey, sortDesc),
|
||||
[documents, sortKey, sortDesc]
|
||||
);
|
||||
|
||||
const allSelectedOnPage = sorted.length > 0 && sorted.every((d) => selectedIds.has(d.id));
|
||||
const someSelectedOnPage = sorted.some((d) => selectedIds.has(d.id)) && !allSelectedOnPage;
|
||||
|
||||
const toggleAll = (checked: boolean) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (checked) sorted.forEach((d) => next.add(d.id));
|
||||
else sorted.forEach((d) => next.delete(d.id));
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
const toggleOne = (id: number, checked: boolean) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (checked) next.add(id);
|
||||
else next.delete(id);
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
const onSortHeader = (key: SortKey) => onSortChange(key);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="rounded-md border mt-6 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-2">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-b-2 border-primary"></div>
|
||||
<p className="text-sm text-muted-foreground">Loading documents...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-[400px] w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<p className="text-sm text-destructive">Error loading documents</p>
|
||||
<Button variant="outline" size="sm" onClick={() => onRefresh()} className="mt-2">
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : sorted.length === 0 ? (
|
||||
<div className="flex h-[400px] w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<FileX className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No documents found</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="hidden md:block max-h-[60vh] overflow-auto">
|
||||
<Table className="table-fixed w-full">
|
||||
<TableHeader className="sticky top-0 bg-background z-10">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead style={{ width: 28 }}>
|
||||
<Checkbox
|
||||
checked={allSelectedOnPage || (someSelectedOnPage && "indeterminate")}
|
||||
onCheckedChange={(v) => toggleAll(!!v)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</TableHead>
|
||||
{columnVisibility.title && (
|
||||
<TableHead style={{ width: 250 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2"
|
||||
onClick={() => onSortHeader("title")}
|
||||
>
|
||||
Title
|
||||
{sortKey === "title" ? (
|
||||
sortDesc ? (
|
||||
<ChevronDown className="shrink-0 opacity-60" size={16} />
|
||||
) : (
|
||||
<ChevronUp className="shrink-0 opacity-60" size={16} />
|
||||
)
|
||||
) : null}
|
||||
</Button>
|
||||
</TableHead>
|
||||
)}
|
||||
{columnVisibility.document_type && (
|
||||
<TableHead style={{ width: 180 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2"
|
||||
onClick={() => onSortHeader("document_type")}
|
||||
>
|
||||
Type
|
||||
{sortKey === "document_type" ? (
|
||||
sortDesc ? (
|
||||
<ChevronDown className="shrink-0 opacity-60" size={16} />
|
||||
) : (
|
||||
<ChevronUp className="shrink-0 opacity-60" size={16} />
|
||||
)
|
||||
) : null}
|
||||
</Button>
|
||||
</TableHead>
|
||||
)}
|
||||
{columnVisibility.content && (
|
||||
<TableHead style={{ width: 300 }}>Content Summary</TableHead>
|
||||
)}
|
||||
{columnVisibility.created_at && (
|
||||
<TableHead style={{ width: 120 }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2"
|
||||
onClick={() => onSortHeader("created_at")}
|
||||
>
|
||||
Created At
|
||||
{sortKey === "created_at" ? (
|
||||
sortDesc ? (
|
||||
<ChevronDown className="shrink-0 opacity-60" size={16} />
|
||||
) : (
|
||||
<ChevronUp className="shrink-0 opacity-60" size={16} />
|
||||
)
|
||||
) : null}
|
||||
</Button>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead style={{ width: 60 }}>
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sorted.map((doc, index) => {
|
||||
const Icon = getDocumentTypeIcon(doc.document_type);
|
||||
const title = doc.title;
|
||||
const truncatedTitle = title.length > 30 ? `${title.slice(0, 30)}...` : title;
|
||||
return (
|
||||
<motion.tr
|
||||
key={doc.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
delay: index * 0.03,
|
||||
},
|
||||
}}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="border-b transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<TableCell className="px-4 py-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(doc.id)}
|
||||
onCheckedChange={(v) => toggleOne(doc.id, !!v)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
</TableCell>
|
||||
{columnVisibility.title && (
|
||||
<TableCell className="px-4 py-3">
|
||||
<motion.div
|
||||
className="flex items-center gap-2 font-medium"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
transition={{ type: "spring", stiffness: 300 }}
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon size={16} className="text-muted-foreground shrink-0" />
|
||||
<span>{truncatedTitle}</span>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{title}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
</TableCell>
|
||||
)}
|
||||
{columnVisibility.document_type && (
|
||||
<TableCell className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<DocumentTypeChip type={doc.document_type} />
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
{columnVisibility.content && (
|
||||
<TableCell className="px-4 py-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="max-w-[300px] max-h-[60px] overflow-hidden text-sm text-muted-foreground">
|
||||
{truncate(doc.content)}
|
||||
</div>
|
||||
<DocumentViewer
|
||||
title={doc.title}
|
||||
content={doc.content}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="w-fit text-xs">
|
||||
View Full Content
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
{columnVisibility.created_at && (
|
||||
<TableCell className="px-4 py-3">
|
||||
{new Date(doc.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="px-4 py-3">
|
||||
<RowActions
|
||||
document={doc}
|
||||
deleteDocument={deleteDocument}
|
||||
refreshDocuments={async () => {
|
||||
await onRefresh();
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</motion.tr>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="md:hidden divide-y">
|
||||
{sorted.map((doc) => {
|
||||
const Icon = getDocumentTypeIcon(doc.document_type);
|
||||
return (
|
||||
<div key={doc.id} className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={selectedIds.has(doc.id)}
|
||||
onCheckedChange={(v) => toggleOne(doc.id, !!v)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon size={16} className="text-muted-foreground shrink-0" />
|
||||
<div className="font-medium truncate">{doc.title}</div>
|
||||
</div>
|
||||
<RowActions
|
||||
document={doc}
|
||||
deleteDocument={deleteDocument}
|
||||
refreshDocuments={async () => {
|
||||
await onRefresh();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||
<DocumentTypeChip type={doc.document_type} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(doc.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
{columnVisibility.content && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{truncate(doc.content)}
|
||||
<div className="mt-1">
|
||||
<DocumentViewer
|
||||
title={doc.title}
|
||||
content={doc.content}
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-fit text-xs p-0 h-auto"
|
||||
>
|
||||
View Full Content
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,163 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export function PaginationControls({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
total,
|
||||
onPageSizeChange,
|
||||
onFirst,
|
||||
onPrev,
|
||||
onNext,
|
||||
onLast,
|
||||
canPrev,
|
||||
canNext,
|
||||
id,
|
||||
}: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPageSizeChange: (size: number) => void;
|
||||
onFirst: () => void;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
onLast: () => void;
|
||||
canPrev: boolean;
|
||||
canNext: boolean;
|
||||
id: string;
|
||||
}) {
|
||||
const start = total === 0 ? 0 : pageIndex * pageSize + 1;
|
||||
const end = Math.min((pageIndex + 1) * pageSize, total);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-8 mt-6">
|
||||
<motion.div
|
||||
className="flex items-center gap-3"
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
>
|
||||
<Label htmlFor={id} className="max-sm:sr-only">
|
||||
Rows per page
|
||||
</Label>
|
||||
<Select value={String(pageSize)} onValueChange={(v) => onPageSizeChange(Number(v))}>
|
||||
<SelectTrigger id={id} className="w-fit whitespace-nowrap">
|
||||
<SelectValue placeholder="Select number of results" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[5, 10, 25, 50].map((s) => (
|
||||
<SelectItem key={s} value={String(s)}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="flex grow justify-end whitespace-nowrap text-sm text-muted-foreground"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<p className="whitespace-nowrap text-sm text-muted-foreground" aria-live="polite">
|
||||
<span className="text-foreground">
|
||||
{start}-{end}
|
||||
</span>{" "}
|
||||
of <span className="text-foreground">{total}</span>
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={onFirst}
|
||||
disabled={!canPrev}
|
||||
aria-label="Go to first page"
|
||||
>
|
||||
<ChevronFirst size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={onPrev}
|
||||
disabled={!canPrev}
|
||||
aria-label="Go to previous page"
|
||||
>
|
||||
<ChevronLeft size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={onNext}
|
||||
disabled={!canNext}
|
||||
aria-label="Go to next page"
|
||||
>
|
||||
<ChevronRight size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="disabled:pointer-events-none disabled:opacity-50"
|
||||
onClick={onLast}
|
||||
disabled={!canNext}
|
||||
aria-label="Go to last page"
|
||||
>
|
||||
<ChevronLast size={16} strokeWidth={2} aria-hidden="true" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,109 @@
|
|||
"use client";
|
||||
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { Document } from "./types";
|
||||
|
||||
export function RowActions({
|
||||
document,
|
||||
deleteDocument,
|
||||
refreshDocuments,
|
||||
}: {
|
||||
document: Document;
|
||||
deleteDocument: (id: number) => Promise<boolean>;
|
||||
refreshDocuments: () => Promise<void>;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const ok = await deleteDocument(document.id);
|
||||
if (ok) toast.success("Document deleted successfully");
|
||||
else toast.error("Failed to delete document");
|
||||
await refreshDocuments();
|
||||
} catch (error) {
|
||||
console.error("Error deleting document:", error);
|
||||
toast.error("Failed to delete document");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<JsonMetadataViewer
|
||||
title={document.title}
|
||||
metadata={document.document_metadata}
|
||||
trigger={
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
View Metadata
|
||||
</DropdownMenuItem>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuSeparator />
|
||||
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setIsOpen(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
export type DocumentType = string;
|
||||
|
||||
export type Document = {
|
||||
id: number;
|
||||
title: string;
|
||||
document_type: DocumentType;
|
||||
document_metadata: any;
|
||||
content: string;
|
||||
created_at: string;
|
||||
search_space_id: number;
|
||||
};
|
||||
|
||||
export type ColumnVisibility = {
|
||||
title: boolean;
|
||||
document_type: boolean;
|
||||
content: boolean;
|
||||
created_at: boolean;
|
||||
};
|
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue