From 2e43ce5b46696657edef4b3416afa4d9206d3cb9 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Thu, 8 Jan 2026 02:54:01 +0100 Subject: [PATCH] style: introduce FileInput component and update file upload handling across various components --- .../Chat/Input/Files/AttachFileMenu.tsx | 14 +-- .../components/SidePanel/Agents/Images.tsx | 7 +- .../components/SidePanel/Agents/MCPIcon.tsx | 7 +- .../components/SidePanel/Builder/Images.tsx | 6 +- .../MCPBuilder/MCPServerDialog/index.tsx | 24 ++--- .../sections/BasicInfoSection.tsx | 2 +- .../MCPServerDialog/sections/TrustSection.tsx | 6 +- client/src/locales/en/translation.json | 3 +- packages/client/src/components/FileInput.tsx | 98 +++++++++++++++++++ packages/client/src/components/FileUpload.tsx | 21 +++- .../src/components/OGDialogTemplate.tsx | 76 ++++++++++---- packages/client/src/components/index.ts | 4 + 12 files changed, 206 insertions(+), 62 deletions(-) create mode 100644 packages/client/src/components/FileInput.tsx diff --git a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx index 218328b086..fcd0a220aa 100644 --- a/client/src/components/Chat/Input/Files/AttachFileMenu.tsx +++ b/client/src/components/Chat/Input/Files/AttachFileMenu.tsx @@ -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 = ''; } diff --git a/client/src/components/SidePanel/Agents/Images.tsx b/client/src/components/SidePanel/Agents/Images.tsx index f899fdbbc8..4f67b6c762 100644 --- a/client/src/components/SidePanel/Agents/Images.tsx +++ b/client/src/components/SidePanel/Agents/Images.tsx @@ -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 /> - { handleFileChange(event); diff --git a/client/src/components/SidePanel/Agents/MCPIcon.tsx b/client/src/components/SidePanel/Agents/MCPIcon.tsx index 19fb2944c4..d3d821a2b1 100644 --- a/client/src/components/SidePanel/Agents/MCPIcon.tsx +++ b/client/src/components/SidePanel/Agents/MCPIcon.tsx @@ -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) { {localize('com_agents_mcp_icon_size')} - Use DALLĀ·E */} - setShowDeleteConfirm(isOpen)}> {localize('com_ui_mcp_server_delete_confirm')}

} - selection={{ - selectHandler: handleDelete, - selectClasses: - 'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80', - selectText: isDeleting ? : 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={ + + } /> @@ -205,16 +204,13 @@ export default function MCPServerDialog({ isEditMode ? (
{shouldShowShareButton && server && ( - {errors.title &&

{errors.title.message}

} + {errors.title &&

{errors.title.message}

}
diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/TrustSection.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/TrustSection.tsx index 854ac717b7..ef283a519b 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/TrustSection.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/TrustSection.tsx @@ -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() { {errors.trust && ( -

{localize('com_ui_field_required')}

+

{localize('com_ui_field_required')}

)} ); diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index ccfbb232a7..add7f5a111 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -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", diff --git a/packages/client/src/components/FileInput.tsx b/packages/client/src/components/FileInput.tsx new file mode 100644 index 0000000000..4969052ea0 --- /dev/null +++ b/packages/client/src/components/FileInput.tsx @@ -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, '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 = { + 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 + * + * + * // Images and documents + * + * + * // Custom MIME types + * + * + * // All files + * + * ``` + */ +const FileInput = React.forwardRef( + ({ acceptTypes, multiple = false, ...props }, ref) => { + const accept = getAcceptString(acceptTypes); + + return ; + }, +); + +FileInput.displayName = 'FileInput'; + +export { FileInput, FILE_TYPE_MAP }; diff --git a/packages/client/src/components/FileUpload.tsx b/packages/client/src/components/FileUpload.tsx index 3481a36e8e..6c2a2f86b4 100644 --- a/packages/client/src/components/FileUpload.tsx +++ b/packages/client/src/components/FileUpload.tsx @@ -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) => 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( - ({ children, handleFileChange }, ref) => { + ({ children, handleFileChange, acceptTypes, multiple = true }, ref) => { return ( <> {children} - diff --git a/packages/client/src/components/OGDialogTemplate.tsx b/packages/client/src/components/OGDialogTemplate.tsx index b2f12a31ad..38a8415e81 100644 --- a/packages/client/src/components/OGDialogTemplate.tsx +++ b/packages/client/src/components/OGDialogTemplate.tsx @@ -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={} + */ + selection?: SelectionProps | ReactNode; className?: string; overlayClassName?: string; headerClassName?: string; @@ -49,14 +75,40 @@ const OGDialogTemplate = forwardRef((props: DialogTemplateProps, ref: Ref + {isLoading === true ? ( + + ) : ( + (selectText as React.JSX.Element) + )} + + ); + } else if (selection) { + selectionContent = selection; + } + return ( )} {buttons != null ? buttons : null} - {selection ? ( - - {isLoading === true ? ( - - ) : ( - (selectText as React.JSX.Element) - )} - - ) : null} + {selectionContent} ); diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts index e3e66b7559..4198ea6317 100644 --- a/packages/client/src/components/index.ts +++ b/packages/client/src/components/index.ts @@ -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';