feat: enhance document management with user information and connector dialog

This commit is contained in:
Anish Sarkar 2026-02-04 12:55:38 +05:30
parent 103baa8b7a
commit 90f9fad95c
13 changed files with 665 additions and 644 deletions

View file

@ -211,7 +211,11 @@ async def read_documents(
Permission.DOCUMENTS_READ.value, Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space", "You don't have permission to read documents in this search space",
) )
query = select(Document).filter(Document.search_space_id == search_space_id) query = (
select(Document)
.options(selectinload(Document.created_by))
.filter(Document.search_space_id == search_space_id)
)
count_query = ( count_query = (
select(func.count()) select(func.count())
.select_from(Document) .select_from(Document)
@ -221,6 +225,7 @@ async def read_documents(
# Get documents from all search spaces user has membership in # Get documents from all search spaces user has membership in
query = ( query = (
select(Document) select(Document)
.options(selectinload(Document.created_by))
.join(SearchSpace) .join(SearchSpace)
.join(SearchSpaceMembership) .join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id) .filter(SearchSpaceMembership.user_id == user.id)
@ -261,6 +266,11 @@ async def read_documents(
# Convert database objects to API-friendly format # Convert database objects to API-friendly format
api_documents = [] api_documents = []
for doc in db_documents: for doc in db_documents:
# Get user name (display_name or email fallback)
created_by_name = None
if doc.created_by:
created_by_name = doc.created_by.display_name or doc.created_by.email
api_documents.append( api_documents.append(
DocumentRead( DocumentRead(
id=doc.id, id=doc.id,
@ -273,6 +283,8 @@ async def read_documents(
created_at=doc.created_at, created_at=doc.created_at,
updated_at=doc.updated_at, updated_at=doc.updated_at,
search_space_id=doc.search_space_id, search_space_id=doc.search_space_id,
created_by_id=doc.created_by_id,
created_by_name=created_by_name,
) )
) )
@ -341,7 +353,11 @@ async def search_documents(
Permission.DOCUMENTS_READ.value, Permission.DOCUMENTS_READ.value,
"You don't have permission to read documents in this search space", "You don't have permission to read documents in this search space",
) )
query = select(Document).filter(Document.search_space_id == search_space_id) query = (
select(Document)
.options(selectinload(Document.created_by))
.filter(Document.search_space_id == search_space_id)
)
count_query = ( count_query = (
select(func.count()) select(func.count())
.select_from(Document) .select_from(Document)
@ -351,6 +367,7 @@ async def search_documents(
# Get documents from all search spaces user has membership in # Get documents from all search spaces user has membership in
query = ( query = (
select(Document) select(Document)
.options(selectinload(Document.created_by))
.join(SearchSpace) .join(SearchSpace)
.join(SearchSpaceMembership) .join(SearchSpaceMembership)
.filter(SearchSpaceMembership.user_id == user.id) .filter(SearchSpaceMembership.user_id == user.id)
@ -395,6 +412,11 @@ async def search_documents(
# Convert database objects to API-friendly format # Convert database objects to API-friendly format
api_documents = [] api_documents = []
for doc in db_documents: for doc in db_documents:
# Get user name (display_name or email fallback)
created_by_name = None
if doc.created_by:
created_by_name = doc.created_by.display_name or doc.created_by.email
api_documents.append( api_documents.append(
DocumentRead( DocumentRead(
id=doc.id, id=doc.id,
@ -407,6 +429,8 @@ async def search_documents(
created_at=doc.created_at, created_at=doc.created_at,
updated_at=doc.updated_at, updated_at=doc.updated_at,
search_space_id=doc.search_space_id, search_space_id=doc.search_space_id,
created_by_id=doc.created_by_id,
created_by_name=created_by_name,
) )
) )

View file

@ -53,6 +53,7 @@ class DocumentRead(BaseModel):
updated_at: datetime | None updated_at: datetime | None
search_space_id: int search_space_id: int
created_by_id: UUID | None = None # User who created/uploaded this document created_by_id: UUID | None = None # User who created/uploaded this document
created_by_name: str | None = None # Display name or email of the user who created this document
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View file

@ -13,6 +13,7 @@ import {
llmPreferencesAtom, llmPreferencesAtom,
} from "@/atoms/new-llm-config/new-llm-config-query.atoms"; } from "@/atoms/new-llm-config/new-llm-config-query.atoms";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup"; import { DocumentUploadDialogProvider } from "@/components/assistant-ui/document-upload-popup";
import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb";
import { LayoutDataProvider } from "@/components/layout"; import { LayoutDataProvider } from "@/components/layout";
@ -192,6 +193,8 @@ export function DashboardClientLayout({
<LayoutDataProvider searchSpaceId={searchSpaceId} breadcrumb={<DashboardBreadcrumb />}> <LayoutDataProvider searchSpaceId={searchSpaceId} breadcrumb={<DashboardBreadcrumb />}>
{children} {children}
</LayoutDataProvider> </LayoutDataProvider>
{/* Global connector dialog - triggered from documents page */}
<ConnectorIndicator hideTrigger />
</DocumentUploadDialogProvider> </DocumentUploadDialogProvider>
); );
} }

View file

@ -2,6 +2,7 @@
import type React from "react"; import type React from "react";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export function getDocumentTypeIcon(type: string): React.ReactNode { export function getDocumentTypeIcon(type: string): React.ReactNode {
return getConnectorIcon(type); return getConnectorIcon(type);
@ -14,17 +15,35 @@ export function getDocumentTypeLabel(type: string): string {
.join(" "); .join(" ");
} }
const MAX_LABEL_LENGTH = 28;
export function DocumentTypeChip({ type, className }: { type: string; className?: string }) { export function DocumentTypeChip({ type, className }: { type: string; className?: string }) {
const icon = getDocumentTypeIcon(type); const icon = getDocumentTypeIcon(type);
return ( const fullLabel = getDocumentTypeLabel(type);
const truncatedLabel = fullLabel.length > MAX_LABEL_LENGTH
? `${fullLabel.slice(0, MAX_LABEL_LENGTH)}...`
: fullLabel;
const needsTruncation = fullLabel.length > MAX_LABEL_LENGTH;
const chip = (
<span <span
className={ 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 ?? ""}`}
"inline-flex items-center gap-1.5 rounded-full border border-border bg-primary/5 px-2 py-1 text-xs font-medium " +
(className ?? "")
}
> >
<span className="text-primary">{icon}</span> <span className="opacity-70 flex-shrink-0">{icon}</span>
{getDocumentTypeLabel(type)} <span className="truncate">{truncatedLabel}</span>
</span> </span>
); );
if (needsTruncation) {
return (
<Tooltip>
<TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p>{fullLabel}</p>
</TooltipContent>
</Tooltip>
);
}
return chip;
} }

View file

@ -1,9 +1,20 @@
"use client"; "use client";
import { CircleAlert, CircleX, Columns3, Filter, ListFilter, Trash } from "lucide-react"; import { useSetAtom } from "jotai";
import { AnimatePresence, motion, type Variants } from "motion/react"; import {
CircleAlert,
CircleX,
Columns3,
FilePlus2,
FileType,
SlidersHorizontal,
Trash,
} from "lucide-react";
import { motion } from "motion/react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import React, { useMemo, useRef } from "react"; import React, { useMemo, useRef } from "react";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -17,25 +28,13 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { getDocumentTypeIcon, getDocumentTypeLabel } from "./DocumentTypeIcon";
import type { ColumnVisibility } from "./types"; import type { ColumnVisibility } 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({ export function DocumentsFilters({
typeCounts: typeCountsRecord, typeCounts: typeCountsRecord,
selectedIds, selectedIds,
@ -61,6 +60,10 @@ export function DocumentsFilters({
const id = React.useId(); const id = React.useId();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
// Dialog hooks for action buttons
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const setConnectorDialogOpen = useSetAtom(connectorDialogOpenAtom);
const uniqueTypes = useMemo(() => { const uniqueTypes = useMemo(() => {
return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[]; return Object.keys(typeCountsRecord).sort() as DocumentTypeEnum[];
}, [typeCountsRecord]); }, [typeCountsRecord]);
@ -75,14 +78,41 @@ export function DocumentsFilters({
return ( return (
<motion.div <motion.div
className="flex flex-wrap items-center justify-start gap-3 w-full" className="flex flex-col gap-4"
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.1 }} transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.1 }}
> >
<div className="flex items-center gap-3 flex-wrap w-full sm:w-auto"> {/* Main toolbar row */}
<div className="flex flex-wrap items-center gap-3">
{/* Action Buttons - Left Side */}
<div className="flex items-center gap-2">
<Button
onClick={openUploadDialog}
variant="outline"
size="sm"
className="h-9 gap-2 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
>
<FilePlus2 size={16} />
<span>Upload documents</span>
</Button>
<Button
onClick={() => setConnectorDialogOpen(true)}
variant="outline"
size="sm"
className="h-9 gap-2 bg-white text-gray-700 border-white hover:bg-gray-50 dark:bg-white dark:text-gray-800 dark:hover:bg-gray-100"
>
<SlidersHorizontal size={16} />
<span>Manage connectors</span>
</Button>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Search Input */}
<motion.div <motion.div
className="relative w-full sm:w-auto" className="relative w-[180px]"
initial={{ opacity: 0, y: -10 }} initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }} transition={{ type: "spring", stiffness: 300, damping: 30 }}
@ -90,183 +120,199 @@ export function DocumentsFilters({
<Input <Input
id={`${id}-input`} id={`${id}-input`}
ref={inputRef} ref={inputRef}
className="peer w-full sm:min-w-60 ps-9" 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"
value={searchValue} value={searchValue}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
placeholder={t("filter_placeholder")} placeholder="Filter by title"
type="text" type="text"
aria-label={t("filter_placeholder")} aria-label={t("filter_placeholder")}
/> />
<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) && ( {Boolean(searchValue) && (
<motion.button <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" className="absolute inset-y-0 right-0 flex h-full w-9 items-center justify-center rounded-r-md text-muted-foreground/60 hover:text-foreground transition-colors"
aria-label="Clear filter" aria-label="Clear filter"
onClick={() => { onClick={() => {
onSearch(""); onSearch("");
inputRef.current?.focus(); inputRef.current?.focus();
}} }}
initial={{ opacity: 0, rotate: -90 }} initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, rotate: 0 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, rotate: 90 }} exit={{ opacity: 0, scale: 0.8 }}
whileHover={{ scale: 1.1 }} whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }} whileTap={{ scale: 0.9 }}
> >
<CircleX size={16} strokeWidth={2} aria-hidden="true" /> <CircleX size={14} strokeWidth={2} aria-hidden="true" />
</motion.button> </motion.button>
)} )}
</motion.div> </motion.div>
<Popover> {/* Filter Buttons Group */}
<PopoverTrigger asChild> <div className="flex items-center gap-2 flex-wrap">
<motion.div {/* Type Filter */}
whileHover={{ scale: 1.05 }} <Popover>
whileTap={{ scale: 0.95 }} <PopoverTrigger asChild>
transition={{ type: "spring", stiffness: 400, damping: 17 }} <Button
> variant="outline"
<Button variant="outline"> size="sm"
<Filter className="h-9 gap-2 border-dashed border-border/60 text-muted-foreground hover:text-foreground hover:border-border"
className="-ms-1 me-2 opacity-60" >
size={16} <FileType size={14} className="text-muted-foreground" />
strokeWidth={2} <span className="hidden sm:inline">Type</span>
aria-hidden="true"
/>
Type
{activeTypes.length > 0 && ( {activeTypes.length > 0 && (
<motion.span <span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground">
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} {activeTypes.length}
</motion.span> </span>
)} )}
</Button> </Button>
</motion.div> </PopoverTrigger>
</PopoverTrigger> <PopoverContent className="w-64 !p-0 overflow-hidden" align="end">
<PopoverContent className="min-w-36 p-3" align="start"> <div className="px-2.5 pt-3">
<motion.div initial="hidden" animate="visible" exit="exit" variants={fadeInScale}> <div className="mb-1.5 px-1 text-[11px] font-medium text-muted-foreground">
<div className="space-y-3"> Filter by source
<div className="text-xs font-medium text-muted-foreground">Filters</div> </div>
<div className="space-y-3"> <div className="space-y-0.5 max-h-[300px] overflow-y-auto overflow-x-hidden">
<AnimatePresence> {uniqueTypes.map((value: DocumentTypeEnum, i) => (
{uniqueTypes.map((value: DocumentTypeEnum, i) => ( <button
<motion.div key={value}
key={value} type="button"
className="flex items-center gap-2" 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"
initial={{ opacity: 0, y: -5 }} onClick={() => onToggleType(value, !activeTypes.includes(value))}
animate={{ opacity: 1, y: 0 }} >
exit={{ opacity: 0, y: 5 }} <Checkbox
transition={{ delay: i * 0.05 }} 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"
/>
<Label
htmlFor={`${id}-${i}`}
className="flex flex-1 items-center gap-2 font-normal text-xs cursor-pointer min-w-0"
> >
<Checkbox <span className="opacity-60 flex-shrink-0">{getDocumentTypeIcon(value)}</span>
id={`${id}-${i}`} <span className="truncate min-w-0">{getDocumentTypeLabel(value)}</span>
checked={activeTypes.includes(value)} <span className="text-[10px] text-muted-foreground/70 tabular-nums flex-shrink-0 ml-auto">
onCheckedChange={(checked: boolean) => onToggleType(value, !!checked)} {typeCounts.get(value)}
/> </span>
<Label </Label>
htmlFor={`${id}-${i}`} </button>
className="flex grow justify-between gap-2 font-normal" ))}
> </div>
{value}{" "} {activeTypes.length > 0 && (
<span className="ms-2 text-xs text-muted-foreground"> <div className="mt-1 pt-1 pb-1 border-t border-border/50 pb-1">
{typeCounts.get(value)} <Button
</span> variant="ghost"
</Label> size="sm"
</motion.div> className="w-full h-6 text-[11px]"
))} onClick={() => {
</AnimatePresence> activeTypes.forEach((t) => {
onToggleType(t, false);
});
}}
>
Clear filters
</Button>
</div>
)}
</div>
</PopoverContent>
</Popover>
{/* View/Columns Popover */}
<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"
>
<Columns3 size={14} className="text-muted-foreground" />
<span className="hidden sm:inline">View</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-36 !p-0 overflow-hidden" align="end">
<div className="px-2.5 pt-3 pb-2">
<div className="mb-1.5 px-1 text-[11px] font-medium text-muted-foreground">
Toggle columns
</div>
<div className="space-y-0.5">
{(
[
["document_type", "Source"],
["created_by", "User"],
["created_at", "Created"],
] as Array<[keyof ColumnVisibility, string]>
).map(([key, label], i) => (
<button
key={key}
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"
onClick={() => onToggleColumn(key, !columnVisibility[key])}
>
<Checkbox
id={`${id}-col-${i}`}
checked={columnVisibility[key]}
onCheckedChange={(checked: boolean) => onToggleColumn(key, !!checked)}
className="h-3.5 w-3.5 flex-shrink-0 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/>
<Label
htmlFor={`${id}-col-${i}`}
className="flex flex-1 items-center gap-2 font-normal text-xs cursor-pointer min-w-0"
>
<span className="truncate min-w-0">{label}</span>
</Label>
</button>
))}
</div> </div>
</div> </div>
</motion.div> </PopoverContent>
</PopoverContent> </Popover>
</Popover> </div>
<DropdownMenu> {/* Bulk Delete Button */}
<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 w-full sm:w-auto sm:ml-auto">
{selectedIds.size > 0 && ( {selectedIds.size > 0 && (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button className="w-full sm:w-auto" variant="outline"> <motion.div
<Trash initial={{ opacity: 0, scale: 0.9 }}
className="-ms-1 me-2 opacity-60" animate={{ opacity: 1, scale: 1 }}
size={16} exit={{ opacity: 0, scale: 0.9 }}
strokeWidth={2} >
aria-hidden="true" <Button
/> variant="destructive"
Delete size="sm"
<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"> className="h-9 gap-2"
{selectedIds.size} >
</span> <Trash size={14} />
</Button> 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> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent className="max-w-md">
<div className="flex flex-col gap-2 max-sm:items-center sm:flex-row sm:gap-4"> <div className="flex flex-col gap-2 sm:flex-row sm:gap-4">
<div <div
className="flex size-9 shrink-0 items-center justify-center rounded-full border border-border" className="flex size-10 shrink-0 items-center justify-center rounded-full bg-destructive/10 text-destructive"
aria-hidden="true" aria-hidden="true"
> >
<CircleAlert className="opacity-80" size={16} strokeWidth={2} /> <CircleAlert size={18} strokeWidth={2} />
</div> </div>
<AlertDialogHeader> <AlertDialogHeader className="flex-1">
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> <AlertDialogTitle>Delete {selectedIds.size} document{selectedIds.size !== 1 ? "s" : ""}?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will permanently delete {selectedIds.size}{" "} This action cannot be undone. This will permanently delete the selected {selectedIds.size === 1 ? "document" : "documents"} from your search space.
selected {selectedIds.size === 1 ? "row" : "rows"}.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
</div> </div>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onBulkDelete}>Delete</AlertDialogAction> <AlertDialogAction
onClick={onBulkDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View file

@ -4,9 +4,10 @@ import { ChevronDown, ChevronUp, FileX, Plus } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import React from "react"; import React, { useState } from "react";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup"; import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { DocumentViewer } from "@/components/document-viewer"; import { DocumentViewer } from "@/components/document-viewer";
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
@ -19,7 +20,7 @@ import {
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { DocumentTypeChip, getDocumentTypeIcon } from "./DocumentTypeIcon"; import { DocumentTypeChip } from "./DocumentTypeIcon";
import { RowActions } from "./RowActions"; import { RowActions } from "./RowActions";
import type { ColumnVisibility, Document } from "./types"; import type { ColumnVisibility, Document } from "./types";
@ -36,13 +37,45 @@ function sortDocuments(docs: Document[], key: SortKey, desc: boolean): Document[
return desc ? sorted.reverse() : sorted; return desc ? sorted.reverse() : sorted;
} }
function truncate(text: string, len = 150): string { function formatDate(dateStr: string): string {
const plain = text const date = new Date(dateStr);
.replace(/[#*_`>\-[\]()]+/g, " ") return date.toLocaleDateString("en-US", {
.replace(/\s+/g, " ") year: "numeric",
.trim(); month: "long",
if (plain.length <= len) return plain; day: "numeric",
return `${plain.slice(0, len)}...`; });
}
function SortableHeader({
children,
sortKey,
currentSortKey,
sortDesc,
onSort,
}: {
children: React.ReactNode;
sortKey: SortKey;
currentSortKey: SortKey;
sortDesc: boolean;
onSort: (key: SortKey) => void;
}) {
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"
>
{children}
<span className={`transition-opacity ${isActive ? "opacity-100" : "opacity-0 group-hover:opacity-50"}`}>
{isActive && sortDesc ? (
<ChevronDown size={14} />
) : (
<ChevronUp size={14} />
)}
</span>
</button>
);
} }
export function DocumentsTableShell({ export function DocumentsTableShell({
@ -75,6 +108,9 @@ export function DocumentsTableShell({
const searchSpaceId = params.search_space_id; const searchSpaceId = params.search_space_id;
const { openDialog } = useDocumentUploadDialog(); const { openDialog } = useDocumentUploadDialog();
// State for metadata viewer (opened via Ctrl/Cmd+Click)
const [metadataDoc, setMetadataDoc] = useState<Document | null>(null);
const sorted = React.useMemo( const sorted = React.useMemo(
() => sortDocuments(documents, sortKey, sortDesc), () => sortDocuments(documents, sortKey, sortDesc),
[documents, sortKey, sortDesc] [documents, sortKey, sortDesc]
@ -107,23 +143,23 @@ export function DocumentsTableShell({
return ( return (
<motion.div <motion.div
className="rounded-md border mt-6 overflow-hidden" className="rounded-xl border border-border/50 bg-card/30 backdrop-blur-sm overflow-hidden shadow-sm"
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.2 }} transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.2 }}
> >
{loading ? ( {loading ? (
<div className="flex h-[400px] w-full items-center justify-center"> <div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-3">
<Spinner size="lg" className="text-primary" /> <Spinner size="lg" className="text-primary" />
<p className="text-sm text-muted-foreground">{t("loading")}</p> <p className="text-sm text-muted-foreground">{t("loading")}</p>
</div> </div>
</div> </div>
) : error ? ( ) : error ? (
<div className="flex h-[400px] w-full items-center justify-center"> <div className="flex h-[400px] w-full items-center justify-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-3">
<p className="text-sm text-destructive">{t("error_loading")}</p> <p className="text-sm text-destructive">{t("error_loading")}</p>
<Button variant="outline" size="sm" onClick={() => onRefresh()} className="mt-2"> <Button variant="outline" size="sm" onClick={() => onRefresh()}>
{t("retry")} {t("retry")}
</Button> </Button>
</div> </div>
@ -136,10 +172,10 @@ export function DocumentsTableShell({
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
className="flex flex-col items-center gap-4 max-w-md px-4 text-center" className="flex flex-col items-center gap-4 max-w-md px-4 text-center"
> >
<div className="rounded-full bg-muted p-4"> <div className="rounded-full bg-muted/50 p-4">
<FileX className="h-8 w-8 text-muted-foreground" /> <FileX className="h-8 w-8 text-muted-foreground/60" />
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<h3 className="text-lg font-semibold">{t("no_documents")}</h3> <h3 className="text-lg font-semibold">{t("no_documents")}</h3>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Get started by uploading your first document. Get started by uploading your first document.
@ -153,218 +189,232 @@ export function DocumentsTableShell({
</div> </div>
) : ( ) : (
<> <>
<div className="hidden md:block max-h-[60vh] overflow-auto"> {/* Desktop Table View */}
<Table className="table-fixed w-full"> <div className="hidden md:flex md:flex-col">
<TableHeader className="sticky top-0 bg-background"> {/* Fixed Header */}
<TableRow className="hover:bg-transparent"> <Table>
<TableHead style={{ width: 28 }}> <TableHeader>
<TableRow className="bg-muted/30 hover:bg-muted/30 border-b border-border/50">
<TableHead className="w-[40px] pl-4">
<Checkbox <Checkbox
checked={allSelectedOnPage || (someSelectedOnPage && "indeterminate")} checked={allSelectedOnPage || (someSelectedOnPage && "indeterminate")}
onCheckedChange={(v) => toggleAll(!!v)} onCheckedChange={(v) => toggleAll(!!v)}
aria-label="Select all" aria-label="Select all"
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/> />
</TableHead> </TableHead>
{columnVisibility.title && ( <TableHead className="min-w-[200px]">
<TableHead style={{ width: 250 }}> <SortableHeader
<Button sortKey="title"
variant="ghost" currentSortKey={sortKey}
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2" sortDesc={sortDesc}
onClick={() => onSortHeader("title")} onSort={onSortHeader}
> >
{t("title")} Document
{sortKey === "title" ? ( </SortableHeader>
sortDesc ? ( </TableHead>
<ChevronDown className="shrink-0 opacity-60" size={16} />
) : (
<ChevronUp className="shrink-0 opacity-60" size={16} />
)
) : null}
</Button>
</TableHead>
)}
{columnVisibility.document_type && ( {columnVisibility.document_type && (
<TableHead style={{ width: 180 }}> <TableHead className="w-[160px]">
<Button <SortableHeader
variant="ghost" sortKey="document_type"
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2" currentSortKey={sortKey}
onClick={() => onSortHeader("document_type")} sortDesc={sortDesc}
onSort={onSortHeader}
> >
{t("type")} Source
{sortKey === "document_type" ? ( </SortableHeader>
sortDesc ? (
<ChevronDown className="shrink-0 opacity-60" size={16} />
) : (
<ChevronUp className="shrink-0 opacity-60" size={16} />
)
) : null}
</Button>
</TableHead> </TableHead>
)} )}
{columnVisibility.content && ( {columnVisibility.created_by && (
<TableHead style={{ width: 300 }}>{t("content_summary")}</TableHead> <TableHead className="w-[150px]">
<span className="text-muted-foreground font-medium">User</span>
</TableHead>
)} )}
{columnVisibility.created_at && ( {columnVisibility.created_at && (
<TableHead style={{ width: 120 }}> <TableHead className="w-[150px]">
<Button <SortableHeader
variant="ghost" sortKey="created_at"
className="flex h-full w-full cursor-pointer select-none items-center justify-between gap-2" currentSortKey={sortKey}
onClick={() => onSortHeader("created_at")} sortDesc={sortDesc}
onSort={onSortHeader}
> >
Created At Created
{sortKey === "created_at" ? ( </SortableHeader>
sortDesc ? (
<ChevronDown className="shrink-0 opacity-60" size={16} />
) : (
<ChevronUp className="shrink-0 opacity-60" size={16} />
)
) : null}
</Button>
</TableHead> </TableHead>
)} )}
<TableHead style={{ width: 60 }}> <TableHead className="w-[80px] pr-4">
<span className="sr-only">Actions</span> <span className="sr-only">Actions</span>
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </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">
<span className="text-muted-foreground shrink-0">{icon}</span>
<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">
{t("view_full")}
</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();
}}
searchSpaceId={searchSpaceId as string}
/>
</TableCell>
</motion.tr>
);
})}
</TableBody>
</Table> </Table>
{/* Scrollable Body */}
<div className="max-h-[55vh] overflow-auto">
<Table>
<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
key={doc.id}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
duration: 0.2,
delay: index * 0.02,
},
}}
className={`border-b border-border/30 transition-colors ${
isSelected
? "bg-primary/5 hover:bg-primary/10"
: "hover:bg-muted/40"
}`}
>
<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>
<TableCell className="min-w-[200px] py-3">
<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"
onClick={(e) => {
// Ctrl (Win/Linux) or Cmd (Mac) + Click opens metadata
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
e.stopPropagation();
setMetadataDoc(doc);
}
}}
onKeyDown={(e) => {
// Ctrl/Cmd + Enter opens metadata
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
setMetadataDoc(doc);
}
}}
>
{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
)}
</button>
}
/>
</TableCell>
{columnVisibility.document_type && (
<TableCell className="w-[160px] py-3">
<DocumentTypeChip type={doc.document_type} />
</TableCell>
)}
{columnVisibility.created_by && (
<TableCell className="w-[150px] py-3 text-sm text-muted-foreground truncate">
{doc.created_by_name || "—"}
</TableCell>
)}
{columnVisibility.created_at && (
<TableCell className="w-[150px] py-3 text-sm text-muted-foreground">
{formatDate(doc.created_at)}
</TableCell>
)}
<TableCell className="w-[80px] pr-4 py-3">
<RowActions
document={doc}
deleteDocument={deleteDocument}
refreshDocuments={async () => {
await onRefresh();
}}
searchSpaceId={searchSpaceId as string}
/>
</TableCell>
</motion.tr>
);
})}
</TableBody>
</Table>
</div>
</div> </div>
<div className="md:hidden divide-y">
{sorted.map((doc) => { {/* Mobile Card View */}
const icon = getDocumentTypeIcon(doc.document_type); <div className="md:hidden divide-y divide-border/30">
{sorted.map((doc, index) => {
const isSelected = selectedIds.has(doc.id);
return ( return (
<div key={doc.id} className="p-3"> <motion.div
<div className="flex items-center gap-3"> 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"
}`}
>
<div className="flex items-start gap-3">
<Checkbox <Checkbox
checked={selectedIds.has(doc.id)} checked={isSelected}
onCheckedChange={(v) => toggleOne(doc.id, !!v)} onCheckedChange={(v) => toggleOne(doc.id, !!v)}
aria-label="Select row" aria-label="Select row"
className="mt-0.5 data-[state=checked]:bg-primary data-[state=checked]:border-primary"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center gap-2 min-w-0"> <DocumentViewer
<span className="text-muted-foreground shrink-0">{icon}</span> title={doc.title}
<div className="font-medium truncate">{doc.title}</div> content={doc.content}
</div> trigger={
<div className="mt-1 flex flex-wrap items-center gap-2"> <button
<DocumentTypeChip type={doc.document_type} /> type="button"
<span className="text-xs text-muted-foreground"> 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"
{new Date(doc.created_at).toLocaleDateString()} onClick={(e) => {
</span> // Ctrl (Win/Linux) or Cmd (Mac) + Click opens metadata
</div> if (e.ctrlKey || e.metaKey) {
{columnVisibility.content && ( e.preventDefault();
<div className="mt-2 text-sm text-muted-foreground"> e.stopPropagation();
{truncate(doc.content)} setMetadataDoc(doc);
<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"
>
{t("view_full")}
</Button>
} }
/> }}
</div> onKeyDown={(e) => {
</div> // Ctrl/Cmd + Enter opens metadata
)} if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
e.preventDefault();
setMetadataDoc(doc);
}
}}
>
{doc.title}
</button>
}
/>
<div className="flex flex-wrap items-center gap-2">
<DocumentTypeChip type={doc.document_type} />
{columnVisibility.created_by && doc.created_by_name && (
<span className="text-xs text-muted-foreground">
{doc.created_by_name}
</span>
)}
{columnVisibility.created_at && (
<span className="text-xs text-muted-foreground">
{formatDate(doc.created_at)}
</span>
)}
</div>
</div> </div>
<RowActions <RowActions
document={doc} document={doc}
@ -375,12 +425,22 @@ export function DocumentsTableShell({
searchSpaceId={searchSpaceId as string} searchSpaceId={searchSpaceId as string}
/> />
</div> </div>
</div> </motion.div>
); );
})} })}
</div> </div>
</> </>
)} )}
{/* Metadata Viewer - opened via Ctrl/Cmd+Click on document title */}
<JsonMetadataViewer
title={metadataDoc?.title ?? ""}
metadata={metadataDoc?.document_metadata}
open={!!metadataDoc}
onOpenChange={(open) => {
if (!open) setMetadataDoc(null);
}}
/>
</motion.div> </motion.div>
); );
} }

View file

@ -2,164 +2,89 @@
import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react"; import { ChevronFirst, ChevronLast, ChevronLeft, ChevronRight } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Pagination, PaginationContent, PaginationItem } from "@/components/ui/pagination"; const PAGE_SIZE = 50;
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export function PaginationControls({ export function PaginationControls({
pageIndex, pageIndex,
pageSize,
total, total,
onPageSizeChange,
onFirst, onFirst,
onPrev, onPrev,
onNext, onNext,
onLast, onLast,
canPrev, canPrev,
canNext, canNext,
id,
}: { }: {
pageIndex: number; pageIndex: number;
pageSize: number;
total: number; total: number;
onPageSizeChange: (size: number) => void;
onFirst: () => void; onFirst: () => void;
onPrev: () => void; onPrev: () => void;
onNext: () => void; onNext: () => void;
onLast: () => void; onLast: () => void;
canPrev: boolean; canPrev: boolean;
canNext: boolean; canNext: boolean;
id: string;
}) { }) {
const t = useTranslations("documents"); const start = pageIndex * PAGE_SIZE + 1;
const start = total === 0 ? 0 : pageIndex * pageSize + 1; const end = Math.min((pageIndex + 1) * PAGE_SIZE, total);
const end = Math.min((pageIndex + 1) * pageSize, total);
return ( return (
<div className="flex items-center justify-between gap-8 mt-6"> <motion.div
<motion.div className="flex items-center justify-end gap-3 py-3 px-2"
className="flex items-center gap-3" initial={{ opacity: 0, y: 10 }}
initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, y: 0 }}
animate={{ opacity: 1, x: 0 }} transition={{ type: "spring", stiffness: 300, damping: 30, delay: 0.3 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }} >
> {/* Range indicator */}
<Label htmlFor={id} className="max-sm:sr-only"> <span className="text-sm text-muted-foreground tabular-nums">
{t("rows_per_page")} {start}-{end} of {total}
</Label> </span>
<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 {/* Navigation buttons */}
className="flex grow justify-end whitespace-nowrap text-sm text-muted-foreground" <div className="flex items-center gap-1">
initial={{ opacity: 0 }} <Button
animate={{ opacity: 1 }} variant="ghost"
transition={{ delay: 0.2 }} size="icon"
> className="h-8 w-8 disabled:opacity-40"
<p className="whitespace-nowrap text-sm text-muted-foreground" aria-live="polite"> onClick={onFirst}
<span className="text-foreground"> disabled={!canPrev}
{start}-{end} aria-label="Go to first page"
</span>{" "} >
of <span className="text-foreground">{total}</span> <ChevronFirst size={18} strokeWidth={2} />
</p> </Button>
</motion.div> <Button
variant="ghost"
<div> size="icon"
<Pagination> className="h-8 w-8 disabled:opacity-40"
<PaginationContent> onClick={onPrev}
<PaginationItem> disabled={!canPrev}
<motion.div aria-label="Go to previous page"
whileHover={{ scale: 1.05 }} >
whileTap={{ scale: 0.95 }} <ChevronLeft size={18} strokeWidth={2} />
transition={{ type: "spring", stiffness: 400, damping: 17 }} </Button>
> <Button
<Button variant="ghost"
size="icon" size="icon"
variant="outline" className="h-8 w-8 disabled:opacity-40"
className="disabled:pointer-events-none disabled:opacity-50" onClick={onNext}
onClick={onFirst} disabled={!canNext}
disabled={!canPrev} aria-label="Go to next page"
aria-label="Go to first page" >
> <ChevronRight size={18} strokeWidth={2} />
<ChevronFirst size={16} strokeWidth={2} aria-hidden="true" /> </Button>
</Button> <Button
</motion.div> variant="ghost"
</PaginationItem> size="icon"
<PaginationItem> className="h-8 w-8 disabled:opacity-40"
<motion.div onClick={onLast}
whileHover={{ scale: 1.05 }} disabled={!canNext}
whileTap={{ scale: 0.95 }} aria-label="Go to last page"
transition={{ type: "spring", stiffness: 400, damping: 17 }} >
> <ChevronLast size={18} strokeWidth={2} />
<Button </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>
</div> </motion.div>
); );
} }
export { PAGE_SIZE };

View file

@ -1,11 +1,10 @@
"use client"; "use client";
import { FileText, MoreHorizontal, Pencil, Trash2 } from "lucide-react"; import { MoreHorizontal, Pencil, Trash2 } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { JsonMetadataViewer } from "@/components/json-metadata-viewer";
import { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -43,7 +42,6 @@ export function RowActions({
searchSpaceId: string; searchSpaceId: string;
}) { }) {
const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [isMetadataOpen, setIsMetadataOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const router = useRouter(); const router = useRouter();
@ -104,29 +102,6 @@ export function RowActions({
</Tooltip> </Tooltip>
)} )}
<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={() => setIsMetadataOpen(true)}
>
<FileText className="h-4 w-4" />
<span className="sr-only">View Metadata</span>
</Button>
</motion.div>
</TooltipTrigger>
<TooltipContent side="top">
<p>View Metadata</p>
</TooltipContent>
</Tooltip>
{isDeletable && ( {isDeletable && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@ -170,10 +145,6 @@ export function RowActions({
<span>Edit</span> <span>Edit</span>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem onClick={() => setIsMetadataOpen(true)}>
<FileText className="mr-2 h-4 w-4" />
<span>Metadata</span>
</DropdownMenuItem>
{isDeletable && ( {isDeletable && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => setIsDeleteOpen(true)} onClick={() => setIsDeleteOpen(true)}
@ -187,13 +158,6 @@ export function RowActions({
</DropdownMenu> </DropdownMenu>
</div> </div>
<JsonMetadataViewer
title={document.title}
metadata={document.document_metadata}
open={isMetadataOpen}
onOpenChange={setIsMetadataOpen}
/>
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}> <AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>

View file

@ -8,11 +8,12 @@ export type Document = {
content: string; content: string;
created_at: string; created_at: string;
search_space_id: number; search_space_id: number;
created_by_id?: string | null;
created_by_name?: string | null;
}; };
export type ColumnVisibility = { export type ColumnVisibility = {
title: boolean;
document_type: boolean; document_type: boolean;
content: boolean; created_by: boolean;
created_at: boolean; created_at: boolean;
}; };

View file

@ -2,22 +2,19 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { RefreshCw, SquarePlus, Upload } from "lucide-react";
import { motion } from "motion/react"; import { motion } from "motion/react";
import { useParams, useRouter } from "next/navigation"; import { useParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useId, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import { Button } from "@/components/ui/button";
import type { DocumentTypeEnum } from "@/contracts/types/document.types"; import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service"; import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys"; import { cacheKeys } from "@/lib/query-client/cache-keys";
import { DocumentsFilters } from "./components/DocumentsFilters"; import { DocumentsFilters } from "./components/DocumentsFilters";
import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell"; import { DocumentsTableShell, type SortKey } from "./components/DocumentsTableShell";
import { PaginationControls } from "./components/PaginationControls"; import { PAGE_SIZE, PaginationControls } from "./components/PaginationControls";
import type { ColumnVisibility } from "./components/types"; import type { ColumnVisibility } from "./components/types";
function useDebounced<T>(value: T, delay = 250) { function useDebounced<T>(value: T, delay = 250) {
@ -31,29 +28,20 @@ function useDebounced<T>(value: T, delay = 250) {
export default function DocumentsTable() { export default function DocumentsTable() {
const t = useTranslations("documents"); const t = useTranslations("documents");
const id = useId();
const params = useParams(); const params = useParams();
const router = useRouter();
const searchSpaceId = Number(params.search_space_id); const searchSpaceId = Number(params.search_space_id);
const { openDialog: openUploadDialog } = useDocumentUploadDialog();
const handleNewNote = useCallback(() => {
router.push(`/dashboard/${searchSpaceId}/editor/new`);
}, [router, searchSpaceId]);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const debouncedSearch = useDebounced(search, 250); const debouncedSearch = useDebounced(search, 250);
const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]); const [activeTypes, setActiveTypes] = useState<DocumentTypeEnum[]>([]);
const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility>({ const [columnVisibility, setColumnVisibility] = useState<ColumnVisibility>({
title: true,
document_type: true, document_type: true,
content: true, created_by: true,
created_at: true, created_at: true,
}); });
const [pageIndex, setPageIndex] = useState(0); const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(50); const [sortKey, setSortKey] = useState<SortKey>("created_at");
const [sortKey, setSortKey] = useState<SortKey>("title"); const [sortDesc, setSortDesc] = useState(true);
const [sortDesc, setSortDesc] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom); const { data: rawTypeCounts } = useAtomValue(documentTypeCountsAtom);
const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom); const { mutateAsync: deleteDocumentMutation } = useAtomValue(deleteDocumentMutationAtom);
@ -63,10 +51,10 @@ export default function DocumentsTable() {
() => ({ () => ({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
page: pageIndex, page: pageIndex,
page_size: pageSize, page_size: PAGE_SIZE,
...(activeTypes.length > 0 && { document_types: activeTypes }), ...(activeTypes.length > 0 && { document_types: activeTypes }),
}), }),
[searchSpaceId, pageIndex, pageSize, activeTypes] [searchSpaceId, pageIndex, activeTypes]
); );
// Build search query parameters // Build search query parameters
@ -74,11 +62,11 @@ export default function DocumentsTable() {
() => ({ () => ({
search_space_id: searchSpaceId, search_space_id: searchSpaceId,
page: pageIndex, page: pageIndex,
page_size: pageSize, page_size: PAGE_SIZE,
title: debouncedSearch.trim(), title: debouncedSearch.trim(),
...(activeTypes.length > 0 && { document_types: activeTypes }), ...(activeTypes.length > 0 && { document_types: activeTypes }),
}), }),
[searchSpaceId, pageIndex, pageSize, activeTypes, debouncedSearch] [searchSpaceId, pageIndex, activeTypes, debouncedSearch]
); );
// Use query for fetching documents // Use query for fetching documents
@ -112,17 +100,14 @@ export default function DocumentsTable() {
activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum); activeTypes.length === 0 || activeTypes.includes("SURFSENSE_DOCS" as DocumentTypeEnum);
// Use query for fetching SurfSense docs // Use query for fetching SurfSense docs
const { // eslint-disable-next-line @typescript-eslint/no-unused-vars
data: surfsenseDocsResponse, const { data: surfsenseDocsResponse } = useQuery({
isLoading: isSurfsenseDocsLoading, queryKey: ["surfsense-docs", debouncedSearch, pageIndex, PAGE_SIZE],
refetch: refetchSurfsenseDocs,
} = useQuery({
queryKey: ["surfsense-docs", debouncedSearch, pageIndex, pageSize],
queryFn: () => queryFn: () =>
documentsApiService.getSurfsenseDocs({ documentsApiService.getSurfsenseDocs({
queryParams: { queryParams: {
page: pageIndex, page: pageIndex,
page_size: pageSize, page_size: PAGE_SIZE,
title: debouncedSearch.trim() || undefined, title: debouncedSearch.trim() || undefined,
}, },
}), }),
@ -131,7 +116,8 @@ export default function DocumentsTable() {
}); });
// Transform SurfSense docs to match the Document type // Transform SurfSense docs to match the Document type
const surfsenseDocsAsDocuments: Document[] = useMemo(() => { // eslint-disable-next-line @typescript-eslint/no-unused-vars
const surfsenseDocsAsDocuments = useMemo(() => {
if (!surfsenseDocsResponse?.items) return []; if (!surfsenseDocsResponse?.items) return [];
return surfsenseDocsResponse.items.map((doc) => ({ return surfsenseDocsResponse.items.map((doc) => ({
id: doc.id, id: doc.id,
@ -145,6 +131,7 @@ export default function DocumentsTable() {
}, [surfsenseDocsResponse]); }, [surfsenseDocsResponse]);
// Merge type counts with SURFSENSE_DOCS count // Merge type counts with SURFSENSE_DOCS count
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const typeCounts = useMemo(() => { const typeCounts = useMemo(() => {
const counts = { ...(rawTypeCounts || {}) }; const counts = { ...(rawTypeCounts || {}) };
if (surfsenseDocsResponse?.total) { if (surfsenseDocsResponse?.total) {
@ -165,11 +152,17 @@ export default function DocumentsTable() {
// Display results directly // Display results directly
const displayDocs = documents; const displayDocs = documents;
const displayTotal = total; const displayTotal = total;
const pageStart = pageIndex * pageSize; const pageEnd = Math.min((pageIndex + 1) * PAGE_SIZE, displayTotal);
const pageEnd = Math.min(pageStart + pageSize, displayTotal);
const onToggleType = (type: DocumentTypeEnum, checked: boolean) => { const onToggleType = (type: DocumentTypeEnum, checked: boolean) => {
setActiveTypes((prev) => (checked ? [...prev, type] : prev.filter((t) => t !== type))); setActiveTypes((prev) => {
if (checked) {
// Only add if not already in the array
return prev.includes(type) ? prev : [...prev, type];
} else {
return prev.filter((t) => t !== type);
}
});
setPageIndex(0); setPageIndex(0);
}; };
@ -238,10 +231,21 @@ export default function DocumentsTable() {
} }
}; };
const handleSortChange = useCallback((key: SortKey) => {
setSortKey((currentKey) => {
if (currentKey === key) {
setSortDesc((v) => !v);
return currentKey;
}
setSortDesc(false);
return key;
});
}, []);
useEffect(() => { useEffect(() => {
const mq = window.matchMedia("(max-width: 768px)"); const mq = window.matchMedia("(max-width: 768px)");
const apply = (isSmall: boolean) => { const apply = (isSmall: boolean) => {
setColumnVisibility((prev) => ({ ...prev, content: !isSmall, created_at: !isSmall })); setColumnVisibility((prev) => ({ ...prev, created_by: !isSmall, created_at: !isSmall }));
}; };
apply(mq.matches); apply(mq.matches);
const onChange = (e: MediaQueryListEvent) => apply(e.matches); const onChange = (e: MediaQueryListEvent) => apply(e.matches);
@ -254,34 +258,9 @@ export default function DocumentsTable() {
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
className="w-full px-6 py-4 space-y-6 min-h-[calc(100vh-64px)]" className="w-full max-w-7xl mx-auto px-6 pt-17 pb-6 space-y-6 min-h-[calc(100vh-64px)]"
> >
<motion.div {/* Filters */}
className="flex items-center justify-between"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<div>
<h2 className="text-xl md:text-2xl font-bold tracking-tight">{t("title")}</h2>
<p className="text-xs md:text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
<div className="flex items-center gap-2">
<Button onClick={openUploadDialog} variant="default" size="sm">
<Upload className="w-4 h-4 mr-2" />
{t("upload_documents")}
</Button>
<Button onClick={handleNewNote} variant="outline" size="sm">
<SquarePlus className="w-4 h-4 mr-2" />
{t("create_shared_note")}
</Button>
<Button onClick={refreshCurrentView} variant="outline" size="sm" disabled={isRefreshing}>
<RefreshCw className={`w-4 h-4 mr-2 ${isRefreshing ? "animate-spin" : ""}`} />
{t("refresh")}
</Button>
</div>
</motion.div>
<DocumentsFilters <DocumentsFilters
typeCounts={rawTypeCounts ?? {}} typeCounts={rawTypeCounts ?? {}}
selectedIds={selectedIds} selectedIds={selectedIds}
@ -294,6 +273,7 @@ export default function DocumentsTable() {
onToggleColumn={onToggleColumn} onToggleColumn={onToggleColumn}
/> />
{/* Table */}
<DocumentsTableShell <DocumentsTableShell
documents={displayDocs} documents={displayDocs}
loading={!!loading} loading={!!loading}
@ -305,30 +285,19 @@ export default function DocumentsTable() {
deleteDocument={deleteDocument} deleteDocument={deleteDocument}
sortKey={sortKey} sortKey={sortKey}
sortDesc={sortDesc} sortDesc={sortDesc}
onSortChange={(key) => { onSortChange={handleSortChange}
if (sortKey === key) setSortDesc((v) => !v);
else {
setSortKey(key);
setSortDesc(false);
}
}}
/> />
{/* Pagination */}
<PaginationControls <PaginationControls
pageIndex={pageIndex} pageIndex={pageIndex}
pageSize={pageSize}
total={displayTotal} total={displayTotal}
onPageSizeChange={(s) => {
setPageSize(s);
setPageIndex(0);
}}
onFirst={() => setPageIndex(0)} onFirst={() => setPageIndex(0)}
onPrev={() => setPageIndex((i) => Math.max(0, i - 1))} onPrev={() => setPageIndex((i) => Math.max(0, i - 1))}
onNext={() => setPageIndex((i) => (pageEnd < displayTotal ? i + 1 : i))} onNext={() => setPageIndex((i) => (pageEnd < displayTotal ? i + 1 : i))}
onLast={() => setPageIndex(Math.max(0, Math.ceil(displayTotal / pageSize) - 1))} onLast={() => setPageIndex(Math.max(0, Math.ceil(displayTotal / PAGE_SIZE) - 1))}
canPrev={pageIndex > 0} canPrev={pageIndex > 0}
canNext={pageEnd < displayTotal} canNext={pageEnd < displayTotal}
id={id}
/> />
</motion.div> </motion.div>
); );

View file

@ -0,0 +1,5 @@
import { atom } from "jotai";
// Atom to control the connector dialog open state from anywhere in the app
export const connectorDialogOpenAtom = atom(false);

View file

@ -37,7 +37,7 @@ import { AllConnectorsTab } from "./connector-popup/tabs/all-connectors-tab";
import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view"; import { ConnectorAccountsListView } from "./connector-popup/views/connector-accounts-list-view";
import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view"; import { YouTubeCrawlerView } from "./connector-popup/views/youtube-crawler-view";
export const ConnectorIndicator: FC = () => { export const ConnectorIndicator: FC<{ hideTrigger?: boolean }> = ({ hideTrigger = false }) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { data: currentUser } = useAtomValue(currentUserAtom); const { data: currentUser } = useAtomValue(currentUserAtom);
@ -186,34 +186,36 @@ export const ConnectorIndicator: FC = () => {
return ( return (
<Dialog open={isOpen} onOpenChange={handleOpenChange}> <Dialog open={isOpen} onOpenChange={handleOpenChange}>
<TooltipIconButton {!hideTrigger && (
data-joyride="connector-icon" <TooltipIconButton
tooltip={hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"} data-joyride="connector-icon"
side="bottom" tooltip={hasConnectors ? `Manage ${activeConnectorsCount} connectors` : "Connect your data"}
className={cn( side="bottom"
"size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative", className={cn(
"hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30", "size-[34px] rounded-full p-1 flex items-center justify-center transition-colors relative",
"outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs", "hover:bg-muted-foreground/15 dark:hover:bg-muted-foreground/30",
"border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none" "outline-none focus:outline-none focus-visible:outline-none font-semibold text-xs",
)} "border-0 ring-0 focus:ring-0 shadow-none focus:shadow-none"
aria-label={ )}
hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector" aria-label={
} hasConnectors ? `View ${activeConnectorsCount} connectors` : "Add your first connector"
onClick={() => handleOpenChange(true)} }
> onClick={() => handleOpenChange(true)}
{isLoading ? ( >
<Spinner size="sm" /> {isLoading ? (
) : ( <Spinner size="sm" />
<> ) : (
<Cable className="size-4 stroke-[1.5px]" /> <>
{activeConnectorsCount > 0 && ( <Cable className="size-4 stroke-[1.5px]" />
<span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm"> {activeConnectorsCount > 0 && (
{activeConnectorsCount > 99 ? "99+" : activeConnectorsCount} <span className="absolute -top-0.5 right-0 flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full bg-primary text-primary-foreground shadow-sm">
</span> {activeConnectorsCount > 99 ? "99+" : activeConnectorsCount}
)} </span>
</> )}
)} </>
</TooltipIconButton> )}
</TooltipIconButton>
)}
<DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5"> <DialogContent className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5">
<DialogTitle className="sr-only">Manage Connectors</DialogTitle> <DialogTitle className="sr-only">Manage Connectors</DialogTitle>

View file

@ -1,8 +1,9 @@
import { format } from "date-fns"; import { format } from "date-fns";
import { useAtomValue } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { import {
createConnectorMutationAtom, createConnectorMutationAtom,
deleteConnectorMutationAtom, deleteConnectorMutationAtom,
@ -49,7 +50,8 @@ export const useConnectorDialog = () => {
const { mutateAsync: deleteConnector } = useAtomValue(deleteConnectorMutationAtom); const { mutateAsync: deleteConnector } = useAtomValue(deleteConnectorMutationAtom);
const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom); const { mutateAsync: createConnector } = useAtomValue(createConnectorMutationAtom);
const [isOpen, setIsOpen] = useState(false); // Use global atom for dialog open state so it can be controlled from anywhere
const [isOpen, setIsOpen] = useAtom(connectorDialogOpenAtom);
const [activeTab, setActiveTab] = useState("all"); const [activeTab, setActiveTab] = useState("all");
const [connectingId, setConnectingId] = useState<string | null>(null); const [connectingId, setConnectingId] = useState<string | null>(null);
const [isScrolled, setIsScrolled] = useState(false); const [isScrolled, setIsScrolled] = useState(false);