diff --git a/src/components/Chat/ChatAssistantMessage.stories.tsx b/src/components/Chat/ChatAssistantMessage.stories.tsx index 9b86e73..9ee499d 100644 --- a/src/components/Chat/ChatAssistantMessage.stories.tsx +++ b/src/components/Chat/ChatAssistantMessage.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import { ChatAssistantMessage } from './ChatAssistantMessage' +import { FileCard } from './FileCard' const meta = { title: 'Components/Chat/ChatAssistantMessage', @@ -40,3 +41,13 @@ export const MultipleMessages: Story = { ), } + +export const WithFileAttachment: Story = { + args: { message: '' }, + render: () => ( +
+ + +
+ ), +} diff --git a/src/components/Chat/ChatInput.tsx b/src/components/Chat/ChatInput.tsx index f0d460c..6b034f7 100644 --- a/src/components/Chat/ChatInput.tsx +++ b/src/components/Chat/ChatInput.tsx @@ -1,12 +1,13 @@ import * as React from 'react' import { useState } from 'react' -import { X, FileText, FileImage, FileVideo, File } from 'lucide-react' import { mergeClasses } from '../../utils/classNames' import { ButtonWidget } from '../Button/ButtonWidget' import { ParagraphField } from '../ParagraphField/ParagraphField' +import { FileCard } from './FileCard' export interface ChatInputFile { name: string + size?: number type?: string } @@ -27,23 +28,6 @@ export interface ChatInputProps { className?: string } -const getFileIcon = (type?: string) => { - if (!type) return File - if (type.startsWith('image/')) return FileImage - if (type.startsWith('video/')) return FileVideo - if (type === 'application/pdf' || type.startsWith('text/')) return FileText - return File -} - -const getFileIconColor = (type?: string) => { - if (!type) return 'text-gray-500' - if (type.startsWith('image/')) return 'text-blue-500' - if (type.startsWith('video/')) return 'text-red-500' - if (type === 'application/pdf') return 'text-red-700' - if (type.startsWith('text/')) return 'text-blue-700' - return 'text-gray-500' -} - export const ChatInput: React.FC = ({ placeholder = 'Type a message...', onSubmit, @@ -83,7 +67,7 @@ export const ChatInput: React.FC = ({ input.onchange = (e) => { const selected = (e.target as HTMLInputElement).files if (selected) { - const newFiles = Array.from(selected).map(f => ({ name: f.name, type: f.type })) + const newFiles = Array.from(selected).map(f => ({ name: f.name, size: f.size, type: f.type })) setFiles(prev => [...prev, ...newFiles]) } } @@ -96,27 +80,16 @@ export const ChatInput: React.FC = ({
{files.length > 0 && ( -
- {files.map((file, index) => { - const IconComponent = getFileIcon(file.type) - const iconColor = getFileIconColor(file.type) - return ( -
- - {file.name} - -
- ) - })} +
+ {files.map((file, index) => ( + setFiles(prev => prev.filter((_, i) => i !== index))} + /> + ))}
)} +
+
+ + + +
+ +
+
+ +
+ + + +
+
+
+ ), + footer: , + }, +} diff --git a/src/components/Chat/ChatUserMessage.stories.tsx b/src/components/Chat/ChatUserMessage.stories.tsx index 5f75961..7cd2b42 100644 --- a/src/components/Chat/ChatUserMessage.stories.tsx +++ b/src/components/Chat/ChatUserMessage.stories.tsx @@ -1,5 +1,6 @@ import type { Meta, StoryObj } from '@storybook/react-vite' import { ChatUserMessage } from './ChatUserMessage' +import { FileCard } from './FileCard' const meta = { title: 'Components/Chat/ChatUserMessage', @@ -40,3 +41,13 @@ export const MultipleMessages: Story = {
), } + +export const WithFileAttachment: Story = { + args: { message: '' }, + render: () => ( +
+ + +
+ ), +} diff --git a/src/components/Chat/FileCard.stories.tsx b/src/components/Chat/FileCard.stories.tsx new file mode 100644 index 0000000..23af44c --- /dev/null +++ b/src/components/Chat/FileCard.stories.tsx @@ -0,0 +1,82 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { fn, userEvent, within, expect } from 'storybook/test' +import { FileCard } from './FileCard' + +const meta = { + title: 'Components/Chat/FileCard', + component: FileCard, + tags: ['autodocs'], + parameters: { layout: 'centered' }, +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { fileName: 'document.pdf', fileSize: 1024 * 250 }, +} + +export const WithRemoveButton: Story = { + args: { fileName: 'presentation.pdf', fileSize: 1024 * 1024 * 2.5, showRemove: true }, +} + +export const ImageFile: Story = { + args: { fileName: 'screenshot.png', fileSize: 1024 * 512 }, +} + +export const CodeFile: Story = { + args: { fileName: 'component.tsx', fileSize: 1024 * 45 }, +} + +export const GenericFile: Story = { + args: { fileName: 'data.csv', fileSize: 1024 * 1024 * 1.8 }, +} + +export const LongFileName: Story = { + args: { + fileName: 'this-is-a-very-long-filename-that-should-be-truncated-in-the-display.pdf', + fileSize: 1024 * 500, + showRemove: true, + maxWidth: '320px', + }, +} + +export const LongFileNameReadOnly: Story = { + args: { + fileName: 'another-extremely-long-filename-for-testing-truncation-behavior.tsx', + fileSize: 1024 * 45, + showRemove: false, + maxWidth: '280px', + }, +} + +export const MultipleFilesInMessage: Story = { + render: () => ( +
+ + + +
+ ), +} + +export const MultipleFilesInInput: Story = { + render: () => ( +
+ + + +
+ ), +} + +export const RemoveInteraction: Story = { + args: { fileName: 'test-file.pdf', fileSize: 1024 * 100, showRemove: true, onRemove: fn() }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement) + const removeButton = canvas.getByRole('button', { name: /remove file/i }) + await expect(removeButton).toBeInTheDocument() + await userEvent.click(removeButton) + await expect(args.onRemove).toHaveBeenCalled() + }, +} diff --git a/src/components/Chat/FileCard.tsx b/src/components/Chat/FileCard.tsx new file mode 100644 index 0000000..f91e254 --- /dev/null +++ b/src/components/Chat/FileCard.tsx @@ -0,0 +1,77 @@ +import * as React from 'react' +import { X, File, FileImage, FileCode, FileText } from 'lucide-react' +import { mergeClasses } from '../../utils/classNames' + +export interface FileCardProps { + /** The file name to display */ + fileName: string + /** The file size in bytes */ + fileSize: number + /** Whether to show the remove button */ + showRemove?: boolean + /** Callback when the remove button is clicked */ + onRemove?: () => void + /** Optional file type override (inferred from fileName if not provided) */ + fileType?: string + /** Maximum width of the component (CSS value) */ + maxWidth?: string + /** Additional Tailwind classes */ + className?: string +} + +function getFileExtension(filename: string): string { + const ext = filename.split('.').pop() + return ext ? ext.toUpperCase() : 'FILE' +} + +function getFileIcon(filename: string) { + const ext = filename.split('.').pop()?.toLowerCase() + if (['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp'].includes(ext || '')) + return + if (ext === 'pdf') + return + if (['js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'html', 'css', 'json', 'xml'].includes(ext || '')) + return + return +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` +} + +export const FileCard: React.FC = ({ + fileName, + fileSize, + showRemove = false, + onRemove, + fileType, + maxWidth = '320px', + className, +}) => { + const extension = fileType || getFileExtension(fileName) + const icon = getFileIcon(fileName) + const formattedSize = formatFileSize(fileSize) + + const baseClasses = `flex items-center gap-2 rounded-sm border border-gray-300 bg-white py-2 ${showRemove ? 'pl-3 pr-2' : 'pl-3 pr-4'}` + + return ( +
+ {icon} +
+
{fileName}
+
{extension} • {formattedSize}
+
+ {showRemove && ( + + )} +
+ ) +} diff --git a/src/components/Chat/index.ts b/src/components/Chat/index.ts index 74ffdaa..10eccc8 100644 --- a/src/components/Chat/index.ts +++ b/src/components/Chat/index.ts @@ -1,6 +1,9 @@ export { ChatInput } from './ChatInput' export type { ChatInputProps, ChatInputFile } from './ChatInput' +export { FileCard } from './FileCard' +export type { FileCardProps } from './FileCard' + export { ChatPanel } from './ChatPanel' export type { ChatPanelProps, ChatPanelHeaderAction } from './ChatPanel'