diff --git a/apps/www/app/examples/chat/page.tsx b/apps/www/app/examples/chat/page.tsx index 7e39641..db5ff13 100644 --- a/apps/www/app/examples/chat/page.tsx +++ b/apps/www/app/examples/chat/page.tsx @@ -27,7 +27,7 @@ export default function ChatPage() { return (
- +
) } diff --git a/apps/www/registry/default/ui/chat/chat-input.tsx b/apps/www/registry/default/ui/chat/chat-input.tsx index 1a0cc3e..435637e 100644 --- a/apps/www/registry/default/ui/chat/chat-input.tsx +++ b/apps/www/registry/default/ui/chat/chat-input.tsx @@ -1,29 +1,84 @@ +import { useState } from "react"; import { Button } from "../button"; +import FileUploader from "../file-uploader"; import { Input } from "../input"; +import UploadImagePreview from "../upload-image-preview"; import { ChatHandler } from "./chat.interface"; export default function ChatInput( props: Pick< ChatHandler, - "isLoading" | "handleSubmit" | "handleInputChange" | "input" - >, + | "isLoading" + | "input" + | "onFileUpload" + | "onFileError" + | "handleSubmit" + | "handleInputChange" + > & { + multiModal?: boolean; + }, ) { + const [imageUrl, setImageUrl] = useState(null); + + const onSubmit = (e: React.FormEvent) => { + if (imageUrl) { + props.handleSubmit(e, { + data: { imageUrl: imageUrl }, + }); + setImageUrl(null); + return; + } + props.handleSubmit(e); + }; + + const onRemovePreviewImage = () => setImageUrl(null); + + const handleUploadImageFile = async (file: File) => { + const base64 = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result as string); + reader.onerror = (error) => reject(error); + }); + setImageUrl(base64); + }; + + const handleUploadFile = async (file: File) => { + try { + if (props.multiModal && file.type.startsWith("image/")) { + return await handleUploadImageFile(file); + } + props.onFileUpload?.(file); + } catch (error: any) { + props.onFileError?.(error.message); + } + }; + return (
- - + {imageUrl && ( + + )} +
+ + + +
); } diff --git a/apps/www/registry/default/ui/chat/chat.interface.ts b/apps/www/registry/default/ui/chat/chat.interface.ts index 3256f7f..584a63f 100644 --- a/apps/www/registry/default/ui/chat/chat.interface.ts +++ b/apps/www/registry/default/ui/chat/chat.interface.ts @@ -8,8 +8,15 @@ export interface ChatHandler { messages: Message[]; input: string; isLoading: boolean; - handleSubmit: (e: React.FormEvent) => void; + handleSubmit: ( + e: React.FormEvent, + ops?: { + data?: any; + }, + ) => void; handleInputChange: (e: React.ChangeEvent) => void; reload?: () => void; stop?: () => void; + onFileUpload?: (file: File) => Promise; + onFileError?: (errMsg: string) => void; } diff --git a/apps/www/registry/default/ui/file-uploader.tsx b/apps/www/registry/default/ui/file-uploader.tsx new file mode 100644 index 0000000..bab773e --- /dev/null +++ b/apps/www/registry/default/ui/file-uploader.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { Loader2, Paperclip } from "lucide-react"; +import { ChangeEvent, useState } from "react"; +import { buttonVariants } from "./button"; +import { cn } from "@/lib/utils" + +export interface FileUploaderProps { + config?: { + inputId?: string; + fileSizeLimit?: number; + allowedExtensions?: string[]; + checkExtension?: (extension: string) => string | null; + disabled: boolean; + }; + onFileUpload: (file: File) => Promise; + onFileError?: (errMsg: string) => void; +} + +const DEFAULT_INPUT_ID = "fileInput"; +const DEFAULT_FILE_SIZE_LIMIT = 1024 * 1024 * 50; // 50 MB + +export default function FileUploader({ + config, + onFileUpload, + onFileError, +}: FileUploaderProps) { + const [uploading, setUploading] = useState(false); + + const inputId = config?.inputId || DEFAULT_INPUT_ID; + const fileSizeLimit = config?.fileSizeLimit || DEFAULT_FILE_SIZE_LIMIT; + const allowedExtensions = config?.allowedExtensions; + const defaultCheckExtension = (extension: string) => { + if (allowedExtensions && !allowedExtensions.includes(extension)) { + return `Invalid file type. Please select a file with one of these formats: ${allowedExtensions!.join( + ",", + )}`; + } + return null; + }; + const checkExtension = config?.checkExtension ?? defaultCheckExtension; + + const isFileSizeExceeded = (file: File) => { + return file.size > fileSizeLimit; + }; + + const resetInput = () => { + const fileInput = document.getElementById(inputId) as HTMLInputElement; + fileInput.value = ""; + }; + + const onFileChange = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setUploading(true); + await handleUpload(file); + resetInput(); + setUploading(false); + }; + + const handleUpload = async (file: File) => { + const onFileUploadError = onFileError || window.alert; + const fileExtension = file.name.split(".").pop() || ""; + const extensionFileError = checkExtension(fileExtension); + if (extensionFileError) { + return onFileUploadError(extensionFileError); + } + + if (isFileSizeExceeded(file)) { + return onFileUploadError( + `File size exceeded. Limit is ${fileSizeLimit / 1024 / 1024} MB`, + ); + } + + await onFileUpload(file); + }; + + return ( +
+ + +
+ ); +} diff --git a/apps/www/registry/default/ui/upload-image-preview.tsx b/apps/www/registry/default/ui/upload-image-preview.tsx new file mode 100644 index 0000000..b809056 --- /dev/null +++ b/apps/www/registry/default/ui/upload-image-preview.tsx @@ -0,0 +1,32 @@ +import { XCircleIcon } from "lucide-react"; +import Image from "next/image"; +import { cn } from "@/lib/utils" + +export default function UploadImagePreview({ + url, + onRemove, +}: { + url: string; + onRemove: () => void; +}) { + return ( +
+ Uploaded image + +
+ ); +}