style: introduce FileInput component and update file upload handling across various components

This commit is contained in:
Marco Beretta 2026-01-08 02:54:01 +01:00
parent 0fe503e12a
commit 2e43ce5b46
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
12 changed files with 206 additions and 62 deletions

View file

@ -21,8 +21,10 @@ import {
DropdownPopup,
AttachmentIcon,
SharePointIcon,
FILE_TYPE_MAP,
} from '@librechat/client';
import type { EndpointFileConfig } from 'librechat-data-provider';
import type { FileType } from '@librechat/client';
import {
useAgentToolPermissions,
useAgentCapabilities,
@ -37,7 +39,7 @@ import { ephemeralAgentByConvoId } from '~/store';
import { MenuItemProps } from '~/common';
import { cn } from '~/utils';
type FileUploadType = 'image' | 'document' | 'image_document' | 'image_document_video_audio';
type FileUploadType = FileType;
interface AttachFileMenuProps {
agentId?: string | null;
@ -93,14 +95,8 @@ const AttachFileMenu = ({
return;
}
inputRef.current.value = '';
if (fileType === 'image') {
inputRef.current.accept = 'image/*,.heif,.heic';
} else if (fileType === 'document') {
inputRef.current.accept = '.pdf,application/pdf';
} else if (fileType === 'image_document') {
inputRef.current.accept = 'image/*,.heif,.heic,.pdf,application/pdf';
} else if (fileType === 'image_document_video_audio') {
inputRef.current.accept = 'image/*,.heif,.heic,.pdf,application/pdf,video/*,audio/*';
if (fileType && fileType in FILE_TYPE_MAP) {
inputRef.current.accept = FILE_TYPE_MAP[fileType];
} else {
inputRef.current.accept = '';
}

View file

@ -1,6 +1,6 @@
import { useRef, useState, useEffect, type ReactElement } from 'react';
import * as Ariakit from '@ariakit/react';
import { DropdownPopup, Skeleton } from '@librechat/client';
import { DropdownPopup, FileInput, Skeleton } from '@librechat/client';
import type { MenuItemProps } from '~/common/menus';
import { useLocalize } from '~/hooks';
@ -117,10 +117,9 @@ export function AvatarMenu({
portal
mountByState
/>
<input
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp"
<FileInput
acceptTypes={['image']}
multiple={false}
type="file"
style={{ display: 'none' }}
onChange={(event) => {
handleFileChange(event);

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { SquirclePlusIcon } from '@librechat/client';
import { FileInput, SquirclePlusIcon } from '@librechat/client';
import { useLocalize } from '~/hooks';
interface MCPIconProps {
@ -62,10 +62,9 @@ export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
</span>
<span className="text-xs text-text-secondary">{localize('com_agents_mcp_icon_size')}</span>
</div>
<input
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp"
<FileInput
acceptTypes={['image']}
multiple={false}
type="file"
style={{ display: 'none' }}
onChange={onIconChange}
ref={fileInputRef}

View file

@ -1,5 +1,6 @@
import { useRef } from 'react';
import * as Popover from '@radix-ui/react-popover';
import { FileInput } from '@librechat/client';
export function NoImage() {
return (
@ -118,10 +119,9 @@ export function AvatarMenu({
>
Use DALL·E
</Popover.Close> */}
<input
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp"
<FileInput
acceptTypes={['image']}
multiple={false}
type="file"
style={{ display: 'none' }}
onChange={handleFileChange}
ref={fileInputRef}

View file

@ -112,15 +112,14 @@ export default function MCPServerDialog({
{/* Delete confirmation dialog */}
<OGDialog open={showDeleteConfirm} onOpenChange={(isOpen) => setShowDeleteConfirm(isOpen)}>
<OGDialogTemplate
title={localize('com_ui_delete')}
className="max-w-[450px]"
main={<p className="text-left text-sm">{localize('com_ui_mcp_server_delete_confirm')}</p>}
selection={{
selectHandler: handleDelete,
selectClasses:
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
selectText: isDeleting ? <Spinner /> : localize('com_ui_delete'),
}}
title={localize('com_ui_delete_mcp_server')}
className="w-11/12 max-w-md"
description={localize('com_ui_mcp_server_delete_confirm', { 0: server?.serverName })}
selection={
<Button onClick={handleDelete} variant="destructive" size="sm">
{isDeleting ? <Spinner /> : localize('com_ui_delete')}
</Button>
}
/>
</OGDialog>
@ -205,16 +204,13 @@ export default function MCPServerDialog({
isEditMode ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
variant="destructive"
size="sm"
aria-label={localize('com_ui_delete')}
onClick={() => setShowDeleteConfirm(true)}
disabled={isSubmitting || isDeleting}
>
<div className="flex w-full items-center justify-center gap-2 text-red-500">
<TrashIcon />
</div>
<TrashIcon />
</Button>
{shouldShowShareButton && server && (
<GenericGrantAccessDialog

View file

@ -52,7 +52,7 @@ export default function BasicInfoSection() {
})}
className={cn(errors.title && 'border-text-destructive')}
/>
{errors.title && <p className="text-xs text-text-destructive">{errors.title.message}</p>}
{errors.title && <p className="text-text-destructive text-xs">{errors.title.message}</p>}
</div>
</div>

View file

@ -1,8 +1,8 @@
import { useFormContext, Controller } from 'react-hook-form';
import { Checkbox, Label } from '@librechat/client';
import { useFormContext, Controller } from 'react-hook-form';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
import { useLocalize, useLocalizedConfig } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
export default function TrustSection() {
const localize = useLocalize();
@ -68,7 +68,7 @@ export default function TrustSection() {
</Label>
</div>
{errors.trust && (
<p className="mt-2 text-xs text-red-500">{localize('com_ui_field_required')}</p>
<p className="text-text-destructive mt-2 text-xs">{localize('com_ui_field_required')}</p>
)}
</div>
);

View file

@ -892,6 +892,7 @@
"com_ui_delete_tool": "Delete Tool",
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
"com_ui_delete_tool_save_reminder": "Tool removed. Save the agent to apply changes.",
"com_ui_delete_mcp_server": "Delete MCP Server?",
"com_ui_deleted": "Deleted",
"com_ui_deleting_file": "Deleting file...",
"com_ui_descending": "Desc",
@ -1077,7 +1078,7 @@
"com_ui_mcp_server": "MCP Server",
"com_ui_mcp_server_connection_failed": "Connection attempt to the provided MCP server failed. Please make sure the URL, the server type, and any authentication configuration are correct, then try again. Also ensure the URL is reachable.",
"com_ui_mcp_server_created": "MCP server created successfully",
"com_ui_mcp_server_delete_confirm": "Are you sure you want to delete this MCP server?",
"com_ui_mcp_server_delete_confirm": "Are you sure you want to delete the '{{0}}' MCP server?",
"com_ui_mcp_server_deleted": "MCP server deleted successfully",
"com_ui_mcp_server_role_editor": "MCP Server Editor",
"com_ui_mcp_server_role_editor_desc": "Can view, use, and edit MCP servers",

View file

@ -0,0 +1,98 @@
import * as React from 'react';
export type FileType =
| 'image'
| 'image_document'
| 'image_document_video_audio'
| 'document'
| 'video'
| 'audio'
| 'all';
export interface FileInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type' | 'accept'> {
/**
* Array of file types to accept. Can be specific MIME types or predefined type names.
* Predefined types:
* - 'image': Images only (png, jpg, jpeg, gif, webp, heic, heif)
* - 'document': Documents only (pdf, doc, docx, txt, md, csv, xls, xlsx)
* - 'video': Videos only (mp4, webm, ogg, mov)
* - 'audio': Audio only (mp3, wav, ogg, webm)
* - 'image_document': Images and PDFs
* - 'image_document_video_audio': All media types
* - 'all': All files
* @example ['image']
* @example ['image', 'document']
* @example ['image/png', 'application/pdf']
*/
acceptTypes?: (FileType | string)[];
/**
* Whether to allow multiple files to be selected
* @default false
*/
multiple?: boolean;
}
/**
* Predefined file type mappings matching the codebase's file upload patterns
* These align with the patterns used in AttachFileMenu and throughout LibreChat
*/
const FILE_TYPE_MAP: Record<FileType, string> = {
image: 'image/*,.heif,.heic',
document: '.pdf,application/pdf,.doc,.docx,.txt,.md,.csv,.xls,.xlsx',
video: 'video/*',
audio: 'audio/*',
image_document: 'image/*,.heif,.heic,.pdf,application/pdf',
image_document_video_audio: 'image/*,.heif,.heic,.pdf,application/pdf,video/*,audio/*',
all: '*',
};
/**
* Converts an array of file types to an accept string
*/
function getAcceptString(types?: (FileType | string)[]): string | undefined {
if (!types || types.length === 0) {
return undefined;
}
const acceptValues = types.map((type) => {
// If it's a predefined type, use the mapping
if (type in FILE_TYPE_MAP) {
return FILE_TYPE_MAP[type as FileType];
}
// Otherwise, treat it as a custom MIME type or extension
return type;
});
return acceptValues.join(',');
}
/**
* A reusable file input component with configurable file type acceptance.
*
* @example
* ```tsx
* // Image files only
* <FileInput acceptTypes={['image']} onChange={handleChange} ref={inputRef} />
*
* // Images and documents
* <FileInput acceptTypes={['image', 'document']} multiple onChange={handleChange} />
*
* // Custom MIME types
* <FileInput acceptTypes={['image/png', 'application/json']} onChange={handleChange} />
*
* // All files
* <FileInput acceptTypes={['all']} onChange={handleChange} />
* ```
*/
const FileInput = React.forwardRef<HTMLInputElement, FileInputProps>(
({ acceptTypes, multiple = false, ...props }, ref) => {
const accept = getAcceptString(acceptTypes);
return <input type="file" accept={accept} multiple={multiple} ref={ref} {...props} />;
},
);
FileInput.displayName = 'FileInput';
export { FileInput, FILE_TYPE_MAP };

View file

@ -1,21 +1,34 @@
import React, { forwardRef } from 'react';
import type { FileType } from './FileInput';
import { FileInput } from './FileInput';
type FileUploadProps = {
className?: string;
onClick?: () => void;
children: React.ReactNode;
handleFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
/**
* Array of file types to accept. Can be specific MIME types or predefined type names.
* If not provided, all files are accepted (default behavior).
* @example ['image', 'document']
*/
acceptTypes?: (FileType | string)[];
/**
* Whether to allow multiple files to be selected
* @default true
*/
multiple?: boolean;
};
const FileUpload = forwardRef<HTMLInputElement, FileUploadProps>(
({ children, handleFileChange }, ref) => {
({ children, handleFileChange, acceptTypes, multiple = true }, ref) => {
return (
<>
{children}
<input
<FileInput
ref={ref}
multiple
type="file"
acceptTypes={acceptTypes}
multiple={multiple}
style={{ display: 'none' }}
onChange={handleFileChange}
/>

View file

@ -1,4 +1,4 @@
import { forwardRef, ReactNode, Ref } from 'react';
import { forwardRef, isValidElement, ReactNode, Ref } from 'react';
import {
OGDialogTitle,
OGDialogClose,
@ -19,13 +19,39 @@ type SelectionProps = {
isLoading?: boolean;
};
/**
* Type guard to check if selection is a legacy SelectionProps object
*/
function isSelectionProps(selection: unknown): selection is SelectionProps {
return (
typeof selection === 'object' &&
selection !== null &&
!isValidElement(selection) &&
('selectHandler' in selection ||
'selectClasses' in selection ||
'selectText' in selection ||
'isLoading' in selection)
);
}
type DialogTemplateProps = {
title: string;
description?: string;
main?: ReactNode;
buttons?: ReactNode;
leftButtons?: ReactNode;
selection?: SelectionProps;
/**
* Selection button configuration. Can be either:
* - An object with selectHandler, selectClasses, selectText, isLoading (legacy)
* - A ReactNode for custom selection component
* @example
* // Legacy usage
* selection={{ selectHandler: () => {}, selectText: 'Confirm' }}
* @example
* // Custom component
* selection={<Button onClick={handleConfirm}>Confirm</Button>}
*/
selection?: SelectionProps | ReactNode;
className?: string;
overlayClassName?: string;
headerClassName?: string;
@ -49,14 +75,40 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDi
mainClassName,
headerClassName,
footerClassName,
showCloseButton,
showCloseButton = false,
overlayClassName,
showCancelButton = true,
} = props;
const { selectHandler, selectClasses, selectText, isLoading } = selection || {};
const isLegacySelection = selection && isSelectionProps(selection);
const { selectHandler, selectClasses, selectText, isLoading } = isLegacySelection
? selection
: {};
const defaultSelect =
'bg-gray-800 text-white transition-colors hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-gray-200 dark:text-gray-800 dark:hover:bg-gray-200';
let selectionContent = null;
if (isLegacySelection) {
selectionContent = (
<OGDialogClose
onClick={selectHandler}
disabled={isLoading}
className={`${
selectClasses ?? defaultSelect
} flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm disabled:opacity-80`}
>
{isLoading === true ? (
<Spinner className="size-4 text-text-primary" />
) : (
(selectText as React.JSX.Element)
)}
</OGDialogClose>
);
} else if (selection) {
selectionContent = selection;
}
return (
<OGDialogContent
overlayClassName={overlayClassName}
@ -86,21 +138,7 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref<HTMLDi
</OGDialogClose>
)}
{buttons != null ? buttons : null}
{selection ? (
<OGDialogClose
onClick={selectHandler}
disabled={isLoading}
className={`${
selectClasses ?? defaultSelect
} flex h-10 items-center justify-center rounded-lg border-none px-4 py-2 text-sm disabled:opacity-80`}
>
{isLoading === true ? (
<Spinner className="size-4 text-white" />
) : (
(selectText as React.JSX.Element)
)}
</OGDialogClose>
) : null}
{selectionContent}
</OGDialogFooter>
</OGDialogContent>
);

View file

@ -1,3 +1,6 @@
export { FILE_TYPE_MAP } from './FileInput';
export type { FileType } from './FileInput';
export * from './Accordion';
export * from './AnimatedTabs';
export * from './AlertDialog';
@ -7,6 +10,7 @@ export * from './Checkbox';
export * from './Dialog';
export * from './DropdownMenu';
export * from './HoverCard';
export * from './FileInput';
export * from './Input';
export * from './InputNumber';
export * from './SecretInput';