Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions models/File.ts
Original file line number Diff line number Diff line change
@@ -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();
85 changes: 81 additions & 4 deletions pages/dashboard/project/[id].tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
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';
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<User> & RouteProps<{ id: string }>;
Expand All @@ -31,6 +33,8 @@ export default class ProjectEvaluationPage extends ObservedComponent<

messageStore = new ConsultMessageModel(this.projectId);

fileInputRef = useRef<HTMLInputElement>(null);

get menu() {
const { t } = this.observedContext;

Expand Down Expand Up @@ -76,6 +80,55 @@ export default class ProjectEvaluationPage extends ObservedComponent<
);
};

handleFileButtonClick = () => {
this.fileInputRef.current?.click();
};

handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files || files.length === 0) return;

await this.uploadFilesAndSendMessages(Array.from(files));
event.target.value = '';
};

handlePaste = async (event: ClipboardEvent<HTMLDivElement>) => {
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<HTMLDivElement>) => {
event.preventDefault();
const files = Array.from(event.dataTransfer.files);
if (files.length === 0) return;

await this.uploadFilesAndSendMessages(files);
};

handleDragOver = (event: DragEvent<HTMLDivElement>) => {
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,
Expand Down Expand Up @@ -150,7 +203,13 @@ export default class ProjectEvaluationPage extends ObservedComponent<
<SessionBox {...{ jwtPayload, menu, title }} path={`/dashboard/project/${projectId}`}>
<PageHead title={title} />

<Container maxWidth="md" className="px-4 py-6 pt-16">
<Container
maxWidth="md"
className="px-4 py-6 pt-16"
onPaste={this.handlePaste}
onDrop={this.handleDrop}
onDragOver={this.handleDragOver}
>
<h1 className="sticky top-[4rem] z-1 m-0 py-5 text-3xl font-bold backdrop-blur-md">
{title}
</h1>
Expand All @@ -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 */}
<input
ref={this.fileInputRef}
type="file"
multiple
accept="image/*,.pdf,.doc,.docx,.txt,.md"
className="hidden"
onChange={this.handleFileChange}
/>
{/* File upload button */}
<IconButton
onClick={this.handleFileButtonClick}
disabled={messageStore.uploading > 0}
title={t('attach_files') || 'Attach files'}
size="small"
>
<AttachFile />
</IconButton>
<TextField
name="content"
placeholder={t('type_your_message')}
Expand All @@ -199,4 +276,4 @@ export default class ProjectEvaluationPage extends ObservedComponent<
</SessionBox>
);
}
}
}