mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 18:00:15 +01:00
🎨 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:
parent
bdb222d5f4
commit
dfe5498301
11 changed files with 466 additions and 279 deletions
|
|
@ -12,6 +12,7 @@ export const columns: ColumnDef<TFile>[] = [
|
|||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:bg-surface-hover"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
Name
|
||||
|
|
@ -33,6 +34,7 @@ export const columns: ColumnDef<TFile>[] = [
|
|||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="hover:bg-surface-hover"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
|
||||
>
|
||||
Date
|
||||
|
|
@ -41,7 +43,9 @@ export const columns: ColumnDef<TFile>[] = [
|
|||
);
|
||||
},
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 { TFile } from 'librechat-data-provider';
|
||||
import { useFileMapContext, useChatContext, useToastContext } from '~/Providers';
|
||||
import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
|
||||
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
|
||||
import { useUpdateFiles, useLocalize } from '~/hooks';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import { getFileType } from '~/utils';
|
||||
|
||||
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;
|
||||
if (file.type?.startsWith('image')) {
|
||||
return (
|
||||
<div
|
||||
onClick={handleFileClick}
|
||||
className="flex cursor-pointer gap-2 rounded-md dark:hover:bg-gray-700"
|
||||
>
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{file.type.startsWith('image') ? (
|
||||
<ImagePreview
|
||||
url={file.filepath}
|
||||
className="relative h-10 w-10 shrink-0 overflow-hidden rounded-md"
|
||||
className="h-10 w-10"
|
||||
source={file.source}
|
||||
alt={file.filename}
|
||||
/>
|
||||
<span className="self-center truncate text-xs">{file.filename}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
) : (
|
||||
<FilePreview fileType={getFileType(file.type)} file={file} />
|
||||
)}
|
||||
<span className="truncate text-xs">{file.filename}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { useState } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { ArrowUpLeft } from 'lucide-react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { LucideArrowUpLeft } from 'lucide-react';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
|
|
@ -9,14 +8,20 @@ import {
|
|||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
type ColumnFiltersState,
|
||||
} from '@tanstack/react-table';
|
||||
import type {
|
||||
ColumnDef,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
ColumnFiltersState,
|
||||
} from '@tanstack/react-table';
|
||||
import type { AugmentedColumnDef } from '~/common';
|
||||
import {
|
||||
fileConfig as defaultFileConfig,
|
||||
checkOpenAIStorage,
|
||||
mergeFileConfig,
|
||||
megabyte,
|
||||
isAssistantsEndpoint,
|
||||
type TFile,
|
||||
} from 'librechat-data-provider';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
|
|
@ -27,6 +32,9 @@ import {
|
|||
TableHeader,
|
||||
TableRow,
|
||||
} from '~/components/ui';
|
||||
import { useFileMapContext, useChatContext, useToastContext } from '~/Providers';
|
||||
import { useLocalize, useUpdateFiles } from '~/hooks';
|
||||
import { useGetFileConfig } from '~/data-provider';
|
||||
import store from '~/store';
|
||||
|
||||
interface DataTableProps<TData, TValue> {
|
||||
|
|
@ -36,16 +44,29 @@ interface DataTableProps<TData, TValue> {
|
|||
|
||||
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
|
||||
const localize = useLocalize();
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
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 pagination = useMemo(
|
||||
() => ({
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}),
|
||||
[pageIndex, pageSize],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
|
|
@ -54,14 +75,6 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
|||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
pagination: paginationState,
|
||||
},
|
||||
defaultColumn: {
|
||||
minSize: 0,
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center gap-4 py-4">
|
||||
<div role="region" aria-label={localize('com_files_table')} className="mt-2 space-y-2">
|
||||
<div className="flex items-center gap-4">
|
||||
<Input
|
||||
placeholder={localize('com_files_filter')}
|
||||
value={(table.getColumn('filename')?.getFilterValue() as string) ?? ''}
|
||||
value={filenameFilter ?? ''}
|
||||
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 className="overflow-y-auto rounded-md border border-black/10 dark:border-white/10">
|
||||
<Table className="border-separate border-spacing-0 ">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup, index) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
|
||||
<div className="rounded-lg border border-border-light bg-transparent shadow-sm transition-colors">
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="border-b border-border-light">
|
||||
{headerGroup.headers.map((header, index) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
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
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
<div className="px-4">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
</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>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
{localize('com_files_no_results')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between py-4">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowFiles(true)}
|
||||
className="flex gap-2"
|
||||
>
|
||||
<LucideArrowUpLeft className="icon-sm" />
|
||||
{localize('com_sidepanel_manage_files')}
|
||||
</Button>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
className="border-b border-border-light transition-colors hover:bg-surface-secondary [&:last-child]:border-0"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const isFilenameCell = cell.column.id === 'filename';
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
data-skip-refocus="true"
|
||||
key={cell.id}
|
||||
role={isFilenameCell ? 'button' : undefined}
|
||||
tabIndex={isFilenameCell ? 0 : undefined}
|
||||
onClick={(e) => {
|
||||
if (isFilenameCell) {
|
||||
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 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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
aria-label={localize('com_ui_prev')}
|
||||
>
|
||||
{localize('com_ui_prev')}
|
||||
</Button>
|
||||
<div aria-live="polite" className="text-sm">
|
||||
{`${pageIndex + 1} / ${table.getPageCount()}`}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
aria-label={localize('com_ui_next')}
|
||||
>
|
||||
{localize('com_ui_next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue