mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-14 22:52:15 +02:00
feat: enhance document management with user information and connector dialog
This commit is contained in:
parent
103baa8b7a
commit
90f9fad95c
13 changed files with 665 additions and 644 deletions
|
|
@ -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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue