🎨 feat: enhance UI & accessibility in file handling components (#5086)

*  feat: Add localization for page display and enhance button styles

*  refactor: improve image preview component styles

*  refactor: enhance modal close behavior and prevent refocus on certain elements

*  refactor: enhance file row layout and improve image preview animation
This commit is contained in:
Marco Beretta 2024-12-23 11:14:40 +01:00 committed by GitHub
parent bdb222d5f4
commit dfe5498301
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 466 additions and 279 deletions

View file

@ -73,8 +73,9 @@ export default function FileRow({
} }
const renderFiles = () => { const renderFiles = () => {
// Inline style for RTL const rowStyle = isRTL
const rowStyle = isRTL ? { display: 'flex', flexDirection: 'row-reverse' } : {}; ? { display: 'flex', flexDirection: 'row-reverse', gap: '4px' }
: { display: 'flex', gap: '4px' };
return ( return (
<div style={rowStyle as React.CSSProperties}> <div style={rowStyle as React.CSSProperties}>

View file

@ -1,3 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { Maximize2 } from 'lucide-react';
import { FileSources } from 'librechat-data-provider'; import { FileSources } from 'librechat-data-provider';
import ProgressCircle from './ProgressCircle'; import ProgressCircle from './ProgressCircle';
import SourceIcon from './SourceIcon'; import SourceIcon from './SourceIcon';
@ -10,67 +12,199 @@ type styleProps = {
backgroundRepeat?: string; backgroundRepeat?: string;
}; };
interface CloseModalEvent {
stopPropagation: () => void;
preventDefault: () => void;
}
const ImagePreview = ({ const ImagePreview = ({
imageBase64, imageBase64,
url, url,
progress = 1, progress = 1,
className = '', className = '',
source, source,
alt = 'Preview image',
}: { }: {
imageBase64?: string; imageBase64?: string;
url?: string; url?: string;
progress?: number; // between 0 and 1 progress?: number;
className?: string; className?: string;
source?: FileSources; source?: FileSources;
alt?: string;
}) => { }) => {
let style: styleProps = { const [isModalOpen, setIsModalOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [previousActiveElement, setPreviousActiveElement] = useState<Element | null>(null);
const openModal = useCallback(() => {
setPreviousActiveElement(document.activeElement);
setIsModalOpen(true);
}, []);
const closeModal = useCallback(
(e: CloseModalEvent): void => {
setIsModalOpen(false);
e.stopPropagation();
e.preventDefault();
if (
previousActiveElement instanceof HTMLElement &&
!previousActiveElement.closest('[data-skip-refocus="true"]')
) {
previousActiveElement.focus();
}
},
[previousActiveElement],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeModal(e);
}
},
[closeModal],
);
useEffect(() => {
if (isModalOpen) {
document.addEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'hidden';
const closeButton = document.querySelector('[aria-label="Close full view"]') as HTMLElement;
if (closeButton) {
setTimeout(() => closeButton.focus(), 0);
}
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = 'unset';
};
}, [isModalOpen, handleKeyDown]);
const baseStyle: styleProps = {
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
backgroundRepeat: 'no-repeat', backgroundRepeat: 'no-repeat',
}; };
if (imageBase64) {
style = {
...style,
backgroundImage: `url(${imageBase64})`,
};
} else if (url) {
style = {
...style,
backgroundImage: `url(${url})`,
};
}
if (!style.backgroundImage) { const imageUrl = imageBase64 ?? url ?? '';
const style: styleProps = imageUrl
? {
...baseStyle,
backgroundImage: `url(${imageUrl})`,
}
: baseStyle;
if (typeof style.backgroundImage !== 'string' || style.backgroundImage.length === 0) {
return null; return null;
} }
const radius = 55; // Radius of the SVG circle const radius = 55;
const circumference = 2 * Math.PI * radius; const circumference = 2 * Math.PI * radius;
// Calculate the offset based on the loading progress
const offset = circumference - progress * circumference; const offset = circumference - progress * circumference;
const circleCSSProperties = { const circleCSSProperties = {
transition: 'stroke-dashoffset 0.3s linear', transition: 'stroke-dashoffset 0.3s linear',
}; };
return ( return (
<div className={cn('h-14 w-14', className)}> <>
<button <div
type="button" className={cn('relative size-14 rounded-lg', className)}
aria-haspopup="dialog" onMouseEnter={() => setIsHovered(true)}
aria-expanded="false" onMouseLeave={() => setIsHovered(false)}
className="h-full w-full" >
style={style} <button
/> type="button"
{progress < 1 && ( className="size-full overflow-hidden rounded-lg"
<ProgressCircle style={style}
circumference={circumference} aria-label={`View ${alt} in full size`}
offset={offset} aria-haspopup="dialog"
circleCSSProperties={circleCSSProperties} onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openModal();
}}
/> />
{progress < 1 ? (
<ProgressCircle
circumference={circumference}
offset={offset}
circleCSSProperties={circleCSSProperties}
aria-label={`Loading progress: ${Math.round(progress * 100)}%`}
/>
) : (
<div
className={cn(
'absolute inset-0 flex cursor-pointer items-center justify-center rounded-lg transition-opacity duration-200 ease-in-out',
isHovered ? 'bg-black/20 opacity-100' : 'opacity-0',
)}
onClick={(e) => {
e.stopPropagation();
openModal();
}}
aria-hidden="true"
>
<Maximize2
className={cn(
'size-5 transform-gpu text-white drop-shadow-lg transition-all duration-200',
isHovered ? 'scale-110' : '',
)}
/>
</div>
)}
<SourceIcon source={source} aria-label={source ? `Source: ${source}` : undefined} />
</div>
{isModalOpen && (
<div
role="dialog"
aria-modal="true"
aria-label={`Full view of ${alt}`}
className="fixed inset-0 z-[999] bg-black bg-opacity-80 transition-opacity duration-200 ease-in-out"
onClick={closeModal}
>
<div className="flex h-full w-full cursor-default items-center justify-center">
<button
type="button"
className="absolute right-4 top-4 z-[1000] rounded-full p-2 text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white"
onClick={(e) => {
e.stopPropagation();
closeModal(e);
}}
aria-label="Close full view"
>
<svg
className="h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
<div
className="max-h-[90vh] max-w-[90vw] transform transition-transform duration-50 ease-in-out animate-in zoom-in-90"
role="presentation"
>
<img
src={imageUrl}
alt={alt}
className="max-w-screen max-h-screen object-contain"
onClick={(e) => e.stopPropagation()}
/>
</div>
</div>
</div>
)} )}
<SourceIcon source={source} /> </>
</div>
); );
}; };

View file

@ -2,7 +2,7 @@ export default function RemoveFile({ onRemove }: { onRemove: () => void }) {
return ( return (
<button <button
type="button" type="button"
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full border border-gray-500 bg-gray-500 p-0.5 text-white transition-colors hover:bg-gray-700 hover:opacity-100 group-hover:opacity-100 md:opacity-0" className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full bg-surface-secondary p-0.5 transition-colors duration-200 hover:bg-surface-primary z-50"
onClick={onRemove} onClick={onRemove}
> >
<span> <span>

View file

@ -227,7 +227,7 @@ export default function ArchivedChatsTable() {
<div className="flex items-center justify-end gap-6 px-2 py-4"> <div className="flex items-center justify-end gap-6 px-2 py-4">
<div className="text-sm font-bold text-text-primary"> <div className="text-sm font-bold text-text-primary">
Page {currentPage} of {totalPages} {localize('com_ui_page')} {currentPage} {localize('com_ui_of')} {totalPages}
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
{/* <Button {/* <Button

View file

@ -50,56 +50,57 @@ const BookmarkTable = () => {
const currentRows = filteredRows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize); const currentRows = filteredRows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
return ( return (
<BookmarkContext.Provider value={{ bookmarks }}> <BookmarkContext.Provider value={{ bookmarks }}>
<div className="flex items-center gap-4 py-4"> <div className=" mt-2 space-y-2">
<Input <div className="flex items-center gap-4">
placeholder={localize('com_ui_bookmarks_filter')} <Input
value={searchQuery} aria-label={localize('com_ui_bookmarks_filter')}
onChange={(e) => setSearchQuery(e.target.value)} placeholder={localize('com_ui_bookmarks_filter')}
className="w-full border-border-light placeholder:text-text-secondary" value={searchQuery}
/> onChange={(e) => setSearchQuery(e.target.value)}
</div> />
<div className="overflow-y-auto rounded-md border border-border-light">
<Table className="table-fixed border-separate border-spacing-0">
<TableHeader>
<TableRow>
<TableCell className="w-full bg-header-primary px-3 py-3.5 pl-6">
<div>{localize('com_ui_bookmarks_title')}</div>
</TableCell>
<TableCell className="w-full bg-header-primary px-3 py-3.5 sm:pl-6">
<div>{localize('com_ui_bookmarks_count')}</div>
</TableCell>
</TableRow>
</TableHeader>
<TableBody>{currentRows.map((row) => renderRow(row))}</TableBody>
</Table>
</div>
<div className="flex items-center justify-between py-4">
<div className="pl-1 text-text-secondary">
{localize('com_ui_showing')} {pageIndex * pageSize + 1} -{' '}
{Math.min((pageIndex + 1) * pageSize, filteredRows.length)} {localize('com_ui_of')}{' '}
{filteredRows.length}
</div> </div>
<div className="flex items-center space-x-2"> <div className="overflow-y-auto rounded-md border border-border-light">
<Button <Table className="table-fixed border-separate border-spacing-0">
variant="outline" <TableHeader>
size="sm" <TableRow>
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))} <TableCell className="w-full bg-header-primary px-3 py-3.5 pl-6">
disabled={pageIndex === 0} <div>{localize('com_ui_bookmarks_title')}</div>
> </TableCell>
{localize('com_ui_prev')} <TableCell className="w-full bg-header-primary px-3 py-3.5 sm:pl-6">
</Button> <div>{localize('com_ui_bookmarks_count')}</div>
<Button </TableCell>
variant="outline" </TableRow>
size="sm" </TableHeader>
onClick={() => <TableBody>{currentRows.map((row) => renderRow(row))}</TableBody>
setPageIndex((prev) => </Table>
(prev + 1) * pageSize < filteredRows.length ? prev + 1 : prev, </div>
) <div className="flex items-center justify-between py-4">
} <div className="pl-1 text-text-secondary">
disabled={(pageIndex + 1) * pageSize >= filteredRows.length} {localize('com_ui_page')} {pageIndex + 1} {localize('com_ui_of')}{' '}
> {Math.ceil(filteredRows.length / pageSize)}
{localize('com_ui_next')} </div>
</Button> <div className="flex items-center space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
disabled={pageIndex === 0}
>
{localize('com_ui_prev')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
setPageIndex((prev) =>
(prev + 1) * pageSize < filteredRows.length ? prev + 1 : prev,
)
}
disabled={(pageIndex + 1) * pageSize >= filteredRows.length}
>
{localize('com_ui_next')}
</Button>
</div>
</div> </div>
</div> </div>
</BookmarkContext.Provider> </BookmarkContext.Provider>

View file

@ -12,6 +12,7 @@ export const columns: ColumnDef<TFile>[] = [
return ( return (
<Button <Button
variant="ghost" variant="ghost"
className="hover:bg-surface-hover"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')} onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
> >
Name Name
@ -33,6 +34,7 @@ export const columns: ColumnDef<TFile>[] = [
return ( return (
<Button <Button
variant="ghost" variant="ghost"
className="hover:bg-surface-hover"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')} onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
> >
Date Date
@ -41,7 +43,9 @@ export const columns: ColumnDef<TFile>[] = [
); );
}, },
cell: ({ row }) => ( cell: ({ row }) => (
<span className="flex justify-end text-xs">{formatDate(row.original.updatedAt)}</span> <span className="flex justify-end text-xs">
{formatDate(row.original.updatedAt?.toString() ?? '')}
</span>
), ),
}, },
]; ];

View file

@ -1,116 +1,25 @@
import { useCallback } from 'react';
import {
fileConfig as defaultFileConfig,
checkOpenAIStorage,
mergeFileConfig,
megabyte,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import type { Row } from '@tanstack/react-table'; import type { Row } from '@tanstack/react-table';
import type { TFile } from 'librechat-data-provider'; import type { TFile } from 'librechat-data-provider';
import { useFileMapContext, useChatContext, useToastContext } from '~/Providers';
import ImagePreview from '~/components/Chat/Input/Files/ImagePreview'; import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
import FilePreview from '~/components/Chat/Input/Files/FilePreview'; import FilePreview from '~/components/Chat/Input/Files/FilePreview';
import { useUpdateFiles, useLocalize } from '~/hooks';
import { useGetFileConfig } from '~/data-provider';
import { getFileType } from '~/utils'; import { getFileType } from '~/utils';
export default function PanelFileCell({ row }: { row: Row<TFile> }) { export default function PanelFileCell({ row }: { row: Row<TFile> }) {
const localize = useLocalize();
const fileMap = useFileMapContext();
const { showToast } = useToastContext();
const { setFiles, conversation } = useChatContext();
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const { addFile } = useUpdateFiles(setFiles);
const handleFileClick = useCallback(() => {
const file = row.original;
const endpoint = conversation?.endpoint;
const fileData = fileMap?.[file.file_id];
if (!fileData) {
return;
}
if (!endpoint) {
return showToast({ message: localize('com_ui_attach_error'), status: 'error' });
}
if (checkOpenAIStorage(fileData?.source ?? '') && !isAssistantsEndpoint(endpoint)) {
return showToast({
message: localize('com_ui_attach_error_openai'),
status: 'error',
});
} else if (!checkOpenAIStorage(fileData?.source ?? '') && isAssistantsEndpoint(endpoint)) {
showToast({
message: localize('com_ui_attach_warn_endpoint'),
status: 'warning',
});
}
const { fileSizeLimit, supportedMimeTypes } =
fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default;
if (fileData.bytes > fileSizeLimit) {
return showToast({
message: `${localize('com_ui_attach_error_size')} ${
fileSizeLimit / megabyte
} MB (${endpoint})`,
status: 'error',
});
}
const isSupportedMimeType = defaultFileConfig.checkType(file.type, supportedMimeTypes);
if (!isSupportedMimeType) {
return showToast({
message: `${localize('com_ui_attach_error_type')} ${file.type} (${endpoint})`,
status: 'error',
});
}
addFile({
progress: 1,
attached: true,
file_id: fileData.file_id,
filepath: fileData.filepath,
preview: fileData.filepath,
type: fileData.type,
height: fileData.height,
width: fileData.width,
filename: fileData.filename,
source: fileData.source,
size: fileData.bytes,
});
}, [addFile, fileMap, row.original, conversation, localize, showToast, fileConfig.endpoints]);
const file = row.original; const file = row.original;
if (file.type?.startsWith('image')) {
return ( return (
<div <div className="flex items-center gap-2">
onClick={handleFileClick} {file.type.startsWith('image') ? (
className="flex cursor-pointer gap-2 rounded-md dark:hover:bg-gray-700"
>
<ImagePreview <ImagePreview
url={file.filepath} url={file.filepath}
className="relative h-10 w-10 shrink-0 overflow-hidden rounded-md" className="h-10 w-10"
source={file.source} source={file.source}
alt={file.filename}
/> />
<span className="self-center truncate text-xs">{file.filename}</span> ) : (
</div> <FilePreview fileType={getFileType(file.type)} file={file} />
); )}
} <span className="truncate text-xs">{file.filename}</span>
const fileType = getFileType(file.type);
return (
<div
onClick={handleFileClick}
className="flex cursor-pointer gap-2 rounded-md dark:hover:bg-gray-700"
>
{fileType && <FilePreview fileType={fileType} className="relative" file={file} />}
<span className="self-center truncate">{file.filename}</span>
</div> </div>
); );
} }

View file

@ -1,7 +1,6 @@
import { useState } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { ArrowUpLeft } from 'lucide-react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { LucideArrowUpLeft } from 'lucide-react';
import { useLocalize } from '~/hooks';
import { import {
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
@ -9,14 +8,20 @@ import {
getPaginationRowModel, getPaginationRowModel,
getSortedRowModel, getSortedRowModel,
useReactTable, useReactTable,
type ColumnDef,
type SortingState,
type VisibilityState,
type ColumnFiltersState,
} from '@tanstack/react-table'; } from '@tanstack/react-table';
import type { import {
ColumnDef, fileConfig as defaultFileConfig,
SortingState, checkOpenAIStorage,
VisibilityState, mergeFileConfig,
ColumnFiltersState, megabyte,
} from '@tanstack/react-table'; isAssistantsEndpoint,
import type { AugmentedColumnDef } from '~/common'; type TFile,
} from 'librechat-data-provider';
import { import {
Button, Button,
Input, Input,
@ -27,6 +32,9 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '~/components/ui'; } from '~/components/ui';
import { useFileMapContext, useChatContext, useToastContext } from '~/Providers';
import { useLocalize, useUpdateFiles } from '~/hooks';
import { useGetFileConfig } from '~/data-provider';
import store from '~/store'; import store from '~/store';
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
@ -36,16 +44,29 @@ interface DataTableProps<TData, TValue> {
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) { export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
const localize = useLocalize(); const localize = useLocalize();
const [rowSelection, setRowSelection] = useState({});
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]); const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}); const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [paginationState, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); const [{ pageIndex, pageSize }, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const setShowFiles = useSetRecoilState(store.showFiles); const setShowFiles = useSetRecoilState(store.showFiles);
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize],
);
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
state: {
sorting,
columnFilters,
columnVisibility,
pagination,
},
onSortingChange: setSorting, onSortingChange: setSorting,
onPaginationChange: setPagination, onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
@ -54,14 +75,6 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
pagination: paginationState,
},
defaultColumn: { defaultColumn: {
minSize: 0, minSize: 0,
size: 10, size: 10,
@ -70,100 +83,223 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
}, },
}); });
const fileMap = useFileMapContext();
const { showToast } = useToastContext();
const { setFiles, conversation } = useChatContext();
const { data: fileConfig = defaultFileConfig } = useGetFileConfig({
select: (data) => mergeFileConfig(data),
});
const { addFile } = useUpdateFiles(setFiles);
const handleFileClick = useCallback(
(file: TFile) => {
if (!fileMap?.[file.file_id] || !conversation?.endpoint) {
showToast({
message: localize('com_ui_attach_error'),
status: 'error',
});
return;
}
const fileData = fileMap[file.file_id];
const endpoint = conversation.endpoint;
if (!fileData.source) {
return;
}
const isOpenAIStorage = checkOpenAIStorage(fileData.source);
const isAssistants = isAssistantsEndpoint(endpoint);
if (isOpenAIStorage && !isAssistants) {
showToast({
message: localize('com_ui_attach_error_openai'),
status: 'error',
});
return;
}
if (!isOpenAIStorage && isAssistants) {
showToast({
message: localize('com_ui_attach_warn_endpoint'),
status: 'warning',
});
}
const { fileSizeLimit, supportedMimeTypes } =
fileConfig.endpoints[endpoint] ?? fileConfig.endpoints.default;
if (fileData.bytes > fileSizeLimit) {
showToast({
message: `${localize('com_ui_attach_error_size')} ${
fileSizeLimit / megabyte
} MB (${endpoint})`,
status: 'error',
});
return;
}
if (!defaultFileConfig.checkType(file.type, supportedMimeTypes)) {
showToast({
message: `${localize('com_ui_attach_error_type')} ${file.type} (${endpoint})`,
status: 'error',
});
return;
}
addFile({
progress: 1,
attached: true,
file_id: fileData.file_id,
filepath: fileData.filepath,
preview: fileData.filepath,
type: fileData.type,
height: fileData.height,
width: fileData.width,
filename: fileData.filename,
source: fileData.source,
size: fileData.bytes,
});
},
[addFile, fileMap, conversation, localize, showToast, fileConfig.endpoints],
);
const filenameFilter = table.getColumn('filename')?.getFilterValue() as string;
return ( return (
<> <div role="region" aria-label={localize('com_files_table')} className="mt-2 space-y-2">
<div className="flex items-center gap-4 py-4"> <div className="flex items-center gap-4">
<Input <Input
placeholder={localize('com_files_filter')} placeholder={localize('com_files_filter')}
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''} value={filenameFilter ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)} onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
className="w-full border-border-light placeholder:text-text-secondary" aria-label={localize('com_files_filter')}
/> />
</div> </div>
<div className="overflow-y-auto rounded-md border border-black/10 dark:border-white/10">
<Table className="border-separate border-spacing-0 "> <div className="rounded-lg border border-border-light bg-transparent shadow-sm transition-colors">
<TableHeader> <div className="overflow-x-auto">
{table.getHeaderGroups().map((headerGroup, index) => ( <Table>
<TableRow key={headerGroup.id}> <TableHeader>
{headerGroup.headers.map((header) => { {table.getHeaderGroups().map((headerGroup) => (
return ( <TableRow key={headerGroup.id} className="border-b border-border-light">
{headerGroup.headers.map((header, index) => (
<TableHead <TableHead
key={header.id} key={header.id}
style={{ width: index === 0 ? '75%' : '25%' }} style={{ width: index === 0 ? '75%' : '25%' }}
className="sticky top-0 h-auto bg-white py-1 text-left font-medium text-gray-700 dark:bg-gray-700 dark:text-gray-100" className="bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary"
> >
{header.isPlaceholder <div className="px-4">
? null {header.isPlaceholder
: flexRender(header.column.columnDef.header, header.getContext())} ? null
: flexRender(header.column.columnDef.header, header.getContext())}
</div>
</TableHead> </TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && 'selected'}
className="border-b border-black/10 text-left text-gray-600 dark:border-white/10 dark:text-gray-300 [tr:last-child_&]:border-b-0"
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className="p-2 px-4 [tr[data-disabled=true]_&]:opacity-50"
style={{
maxWidth: (cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta
.size,
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))} ))}
</TableRow> </TableRow>
)) ))}
) : ( </TableHeader>
<TableRow> <TableBody>
<TableCell colSpan={columns.length} className="h-24 text-center"> {table.getRowModel().rows.length ? (
{localize('com_files_no_results')} table.getRowModel().rows.map((row) => (
</TableCell> <TableRow
</TableRow> key={row.id}
)} data-state={row.getIsSelected() && 'selected'}
</TableBody> className="border-b border-border-light transition-colors hover:bg-surface-secondary [&:last-child]:border-0"
</Table> >
</div> {row.getVisibleCells().map((cell) => {
<div className="flex items-center justify-between py-4"> const isFilenameCell = cell.column.id === 'filename';
<div className="flex items-center">
<Button return (
variant="outline" <TableCell
size="sm" data-skip-refocus="true"
onClick={() => setShowFiles(true)} key={cell.id}
className="flex gap-2" role={isFilenameCell ? 'button' : undefined}
> tabIndex={isFilenameCell ? 0 : undefined}
<LucideArrowUpLeft className="icon-sm" /> onClick={(e) => {
{localize('com_sidepanel_manage_files')} if (isFilenameCell) {
</Button> const clickedElement = e.target as HTMLElement;
// Check if clicked element is within cell and not a button/link
if (
clickedElement.closest('td') &&
!clickedElement.closest('button, a')
) {
e.preventDefault();
e.stopPropagation();
handleFileClick(row.original as TFile);
}
}
}}
onKeyDown={(e) => {
if (isFilenameCell && (e.key === 'Enter' || e.key === ' ')) {
const clickedElement = e.target as HTMLElement;
if (
clickedElement.closest('td') &&
!clickedElement.closest('button, a')
) {
e.preventDefault();
e.stopPropagation();
handleFileClick(row.original as TFile);
}
}
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center text-sm text-text-secondary"
>
{localize('com_files_no_results')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div> </div>
<div className="flex items-center gap-2"> </div>
<div className="flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={() => setShowFiles(true)}
aria-label={localize('com_sidepanel_manage_files')}
>
<ArrowUpLeft className="h-4 w-4" />
<span className="ml-2">{localize('com_sidepanel_manage_files')}</span>
</Button>
<div className="flex items-center gap-2" role="navigation" aria-label="Pagination">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => table.previousPage()} onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
aria-label={localize('com_ui_prev')}
> >
{localize('com_ui_prev')} {localize('com_ui_prev')}
</Button> </Button>
<div aria-live="polite" className="text-sm">
{`${pageIndex + 1} / ${table.getPageCount()}`}
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => table.nextPage()} onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
aria-label={localize('com_ui_next')}
> >
{localize('com_ui_next')} {localize('com_ui_next')}
</Button> </Button>
</div> </div>
</div> </div>
</> </div>
); );
} }

View file

@ -4,14 +4,14 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '~/utils'; import { cn } from '~/utils';
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90', default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/80', destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: outline:
'text-text-primary border border-input bg-background hover:bg-accent hover:text-accent-foreground', 'text-text-primary border border-border-light bg-background hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline', link: 'text-primary underline-offset-4 hover:underline',
@ -19,8 +19,8 @@ const buttonVariants = cva(
}, },
size: { size: {
default: 'h-10 px-4 py-2', default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3', sm: 'h-9 rounded-lg px-3',
lg: 'h-11 rounded-md px-8', lg: 'h-11 rounded-lg px-8',
icon: 'size-10', icon: 'size-10',
}, },
}, },

View file

@ -438,6 +438,7 @@ export default {
com_ui_no_conversation_id: 'No conversation ID found', com_ui_no_conversation_id: 'No conversation ID found',
com_ui_add_multi_conversation: 'Add multi-conversation', com_ui_add_multi_conversation: 'Add multi-conversation',
com_ui_duplicate_agent_confirm: 'Are you sure you want to duplicate this agent?', com_ui_duplicate_agent_confirm: 'Are you sure you want to duplicate this agent?',
com_ui_page: 'Page',
com_auth_error_login: com_auth_error_login:
'Unable to login with the information provided. Please check your credentials and try again.', 'Unable to login with the information provided. Please check your credentials and try again.',
com_auth_error_login_rl: com_auth_error_login_rl:

11
package-lock.json generated
View file

@ -34920,9 +34920,10 @@
"integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==" "integrity": "sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ=="
}, },
"node_modules/use-callback-ref": { "node_modules/use-callback-ref": {
"version": "1.3.1", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.1.tgz", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
"integrity": "sha512-Lg4Vx1XZQauB42Hw3kK7JM6yjVjgFmFC5/Ab797s79aARomD2nEErc4mCgM8EZrARLmmbWpi5DGCadmK50DcAQ==", "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==",
"license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.0.0" "tslib": "^2.0.0"
}, },
@ -34930,8 +34931,8 @@
"node": ">=10" "node": ">=10"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", "@types/react": "*",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0" "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"@types/react": { "@types/react": {