diff --git a/models/File.ts b/models/File.ts new file mode 100644 index 0000000..e776323 --- /dev/null +++ b/models/File.ts @@ -0,0 +1,33 @@ +import { toggle } from 'mobx-restful'; +import { FileModel } from 'mobx-restful-table'; +import { blobOf, uniqueID } from 'web-utility'; + +import userStore from './User'; + +export class S3FileModel extends FileModel { + client = userStore.client; + + @toggle('uploading') + async upload(file: string | Blob) { + if (typeof file === 'string') { + const name = file.split('/').pop()!; + + file = new File([await blobOf(file)], name); + } + const { body } = await this.client.post<{ putLink: string; getLink: string }>( + `file/signed-link/${file instanceof File ? file.name : uniqueID()}` + ); + await this.client.put(body!.putLink, file, { 'Content-Type': file.type }); + + return super.upload(body!.getLink); + } + + @toggle('uploading') + async delete(link: string) { + await this.client.delete(`file/${link.replace(`${this.client.baseURI}/file/`, '')}`); + + await super.delete(link); + } +} + +export default new S3FileModel(); \ No newline at end of file diff --git a/pages/dashboard/project/[id].tsx b/pages/dashboard/project/[id].tsx index 42b0e11..af44063 100644 --- a/pages/dashboard/project/[id].tsx +++ b/pages/dashboard/project/[id].tsx @@ -1,10 +1,11 @@ import { ConsultMessage, User, UserRole } from '@idea2app/data-server'; -import { Avatar, Button, Container, Paper, TextField, Typography } from '@mui/material'; +import { Avatar, Button, Container, IconButton, Paper, TextField, Typography } from '@mui/material'; +import { AttachFile } from '@mui/icons-material'; import { marked } from 'marked'; import { observer } from 'mobx-react'; import { ObservedComponent, reaction } from 'mobx-react-helper'; import { compose, JWTProps, jwtVerifier, RouteProps, router } from 'next-ssr-middleware'; -import { FormEvent, KeyboardEventHandler } from 'react'; +import { FormEvent, KeyboardEventHandler, ChangeEvent, ClipboardEvent, DragEvent, useRef } from 'react'; import { formToJSON, scrollTo, sleep } from 'web-utility'; import { PageHead } from '../../../components/PageHead'; @@ -12,6 +13,7 @@ import { EvaluationDisplay } from '../../../components/Project/EvaluationDisplay import { ScrollList } from '../../../components/ScrollList'; import { SessionBox } from '../../../components/User/SessionBox'; import { ConsultMessageModel, ProjectModel } from '../../../models/ProjectEvaluation'; +import fileStore from '../../../models/File'; import { i18n, I18nContext } from '../../../models/Translation'; type ProjectEvaluationPageProps = JWTProps & RouteProps<{ id: string }>; @@ -31,6 +33,8 @@ export default class ProjectEvaluationPage extends ObservedComponent< messageStore = new ConsultMessageModel(this.projectId); + fileInputRef = useRef(null); + get menu() { const { t } = this.observedContext; @@ -76,6 +80,55 @@ export default class ProjectEvaluationPage extends ObservedComponent< ); }; + handleFileButtonClick = () => { + this.fileInputRef.current?.click(); + }; + + handleFileChange = async (event: ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + await this.uploadFilesAndSendMessages(Array.from(files)); + event.target.value = ''; + }; + + handlePaste = async (event: ClipboardEvent) => { + const items = event.clipboardData.items; + const files = items + .filter(item => item.kind === 'file' && item.type.startsWith('image/')) + .map(item => item.getAsFile()) + .filter((file): file is File => file !== null); + + if (files.length === 0) return; + + event.preventDefault(); + await this.uploadFilesAndSendMessages(files); + }; + + handleDrop = async (event: DragEvent) => { + event.preventDefault(); + const files = Array.from(event.dataTransfer.files); + if (files.length === 0) return; + + await this.uploadFilesAndSendMessages(files); + }; + + handleDragOver = (event: DragEvent) => { + event.preventDefault(); + }; + + uploadFilesAndSendMessages = async (files: File[]) => { + for (const file of files) { + try { + const fileUrl = await fileStore.upload(file); + const content = `📎 ${file.name}\n${fileUrl}`; + await this.messageStore.updateOne({ content }); + } catch (error) { + console.error(`Failed to upload file ${file.name}:`, error); + } + } + }; + renderChatMessage = ( { id, content, evaluation, prototypes, createdAt, createdBy }: ConsultMessage, index = 0, @@ -150,7 +203,13 @@ export default class ProjectEvaluationPage extends ObservedComponent< - +

{title}

@@ -175,6 +234,24 @@ export default class ProjectEvaluationPage extends ObservedComponent< className="sticky bottom-0 mx-1 mt-auto mb-1 flex items-end gap-2 p-1.5 sm:mx-0 sm:mb-0 sm:p-2" onSubmit={this.handleMessageSubmit} > + {/* Hidden file input */} + + {/* File upload button */} + 0} + title={t('attach_files') || 'Attach files'} + size="small" + > + + ); } -} +} \ No newline at end of file