mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +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
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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)}>
|
<>
|
||||||
|
<div
|
||||||
|
className={cn('relative size-14 rounded-lg', className)}
|
||||||
|
onMouseEnter={() => setIsHovered(true)}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-haspopup="dialog"
|
className="size-full overflow-hidden rounded-lg"
|
||||||
aria-expanded="false"
|
|
||||||
className="h-full w-full"
|
|
||||||
style={style}
|
style={style}
|
||||||
|
aria-label={`View ${alt} in full size`}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openModal();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{progress < 1 && (
|
{progress < 1 ? (
|
||||||
<ProgressCircle
|
<ProgressCircle
|
||||||
circumference={circumference}
|
circumference={circumference}
|
||||||
offset={offset}
|
offset={offset}
|
||||||
circleCSSProperties={circleCSSProperties}
|
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',
|
||||||
)}
|
)}
|
||||||
<SourceIcon source={source} />
|
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>
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -50,12 +50,13 @@ 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">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
<Input
|
<Input
|
||||||
|
aria-label={localize('com_ui_bookmarks_filter')}
|
||||||
placeholder={localize('com_ui_bookmarks_filter')}
|
placeholder={localize('com_ui_bookmarks_filter')}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full border-border-light placeholder:text-text-secondary"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-y-auto rounded-md border border-border-light">
|
<div className="overflow-y-auto rounded-md border border-border-light">
|
||||||
|
|
@ -75,9 +76,8 @@ const BookmarkTable = () => {
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-4">
|
<div className="flex items-center justify-between py-4">
|
||||||
<div className="pl-1 text-text-secondary">
|
<div className="pl-1 text-text-secondary">
|
||||||
{localize('com_ui_showing')} {pageIndex * pageSize + 1} -{' '}
|
{localize('com_ui_page')} {pageIndex + 1} {localize('com_ui_of')}{' '}
|
||||||
{Math.min((pageIndex + 1) * pageSize, filteredRows.length)} {localize('com_ui_of')}{' '}
|
{Math.ceil(filteredRows.length / pageSize)}
|
||||||
{filteredRows.length}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -102,6 +102,7 @@ const BookmarkTable = () => {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</BookmarkContext.Provider>
|
</BookmarkContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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 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 (
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,34 +83,119 @@ 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">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{table.getHeaderGroups().map((headerGroup, index) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow key={headerGroup.id}>
|
<TableRow key={headerGroup.id} className="border-b border-border-light">
|
||||||
{headerGroup.headers.map((header) => {
|
{headerGroup.headers.map((header, index) => (
|
||||||
return (
|
|
||||||
<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"
|
||||||
>
|
>
|
||||||
|
<div className="px-4">
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -107,25 +205,57 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||||
<TableRow
|
<TableRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
data-state={row.getIsSelected() && 'selected'}
|
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"
|
className="border-b border-border-light transition-colors hover:bg-surface-secondary [&:last-child]:border-0"
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => {
|
||||||
|
const isFilenameCell = cell.column.id === 'filename';
|
||||||
|
|
||||||
|
return (
|
||||||
<TableCell
|
<TableCell
|
||||||
|
data-skip-refocus="true"
|
||||||
key={cell.id}
|
key={cell.id}
|
||||||
className="p-2 px-4 [tr[data-disabled=true]_&]:opacity-50"
|
role={isFilenameCell ? 'button' : undefined}
|
||||||
style={{
|
tabIndex={isFilenameCell ? 0 : undefined}
|
||||||
maxWidth: (cell.column.columnDef as AugmentedColumnDef<TData, TValue>).meta
|
onClick={(e) => {
|
||||||
.size,
|
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())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center text-sm text-text-secondary"
|
||||||
|
>
|
||||||
{localize('com_files_no_results')}
|
{localize('com_files_no_results')}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -133,37 +263,43 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between py-4">
|
</div>
|
||||||
<div className="flex items-center">
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowFiles(true)}
|
onClick={() => setShowFiles(true)}
|
||||||
className="flex gap-2"
|
aria-label={localize('com_sidepanel_manage_files')}
|
||||||
>
|
>
|
||||||
<LucideArrowUpLeft className="icon-sm" />
|
<ArrowUpLeft className="h-4 w-4" />
|
||||||
{localize('com_sidepanel_manage_files')}
|
<span className="ml-2">{localize('com_sidepanel_manage_files')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
11
package-lock.json
generated
|
|
@ -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": {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue