mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-11 08:43:48 +00:00
style: introduce FileInput component and update file upload handling across various components
This commit is contained in:
parent
0fe503e12a
commit
2e43ce5b46
12 changed files with 206 additions and 62 deletions
|
|
@ -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 = '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
98
packages/client/src/components/FileInput.tsx
Normal file
98
packages/client/src/components/FileInput.tsx
Normal 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 };
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue