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
83 changes: 83 additions & 0 deletions components/FilePreview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { ComponentProps, FC } from 'react';

import { SymbolIcon } from './Icon';

export interface FilePreviewProps extends Omit<ComponentProps<'figure'>, 'children'> {
path: string;
type?: ComponentProps<'input'>['accept'];
}

const ExtensionNameMap: Record<string, string> = {
stream: 'binary',
compressed: 'zip',
msword: 'doc',
document: 'docx',
powerpoint: 'ppt',
presentation: 'pptx',
excel: 'xls',
sheet: 'xlsx',
};

const ImageExtension = /\.(apng|avif|bmp|gif|ico|jpe?g|png|svg|webp)$/i;
const AudioExtension = /\.(aac|flac|m4a|mp3|ogg|wav|weba)$/i;
const VideoExtension = /\.(avi|m4v|mov|mp4|mpeg|mpg|webm|wmv)$/i;

function inferFileMeta(path: string, type?: string) {
const [pathname] = path.split(/[?#]/),
[category = '', ...kind] = type?.split(/\W+/) || [];

const fileName = decodeURI(pathname.split('/').at(-1) || pathname);
const extension =
kind[0] === '*' || !kind[0]
? fileName.includes('.')
? fileName.split('.').at(-1)?.toLowerCase()
: ''
: (ExtensionNameMap[kind.at(-1)!] || kind.at(-1))?.toLowerCase();

const isImage = category === 'image' || (!category && ImageExtension.test(pathname));
const isAudio = category === 'audio' || (!category && AudioExtension.test(pathname));
const isVideo = category === 'video' || (!category && VideoExtension.test(pathname));

return { fileName, extension, isImage, isAudio, isVideo };
}

export const FilePreview: FC<FilePreviewProps> = ({ path, type, className = '' }) => {
const { fileName, extension, isImage, isAudio, isVideo } = inferFileMeta(path, type);
const caption = fileName || extension || path;

return (
<figure className={`m-0 ${className}`}>
{isImage ? (
<img
className="max-h-60 max-w-full rounded object-contain"
loading="lazy"
src={path}
alt={fileName}
/>
) : isAudio ? (
<audio className="max-w-full" controls preload="metadata" src={path} />
) : isVideo ? (
<video className="max-h-72 max-w-full rounded" controls preload="metadata" src={path} />
) : null}

<figcaption className="mt-0.5 max-w-full truncate align-middle text-[0.75rem] text-inherit opacity-80">
{isImage || isAudio || isVideo ? (
caption
) : (
<a
className="inline-flex max-w-full items-center gap-1 text-inherit underline"
href={path}
target="_blank"
rel="noreferrer"
download={fileName}
>
<SymbolIcon name="insert_drive_file" className="text-[1.25rem]" />
{caption}
</a>
)}
</figcaption>
</figure>
);
};

FilePreview.displayName = 'FilePreview';
70 changes: 70 additions & 0 deletions components/PasteDropBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { ClipboardEvent, Component, DragEvent, HTMLAttributes, PropsWithChildren } from 'react';
import { groupBy } from 'web-utility';

export interface PasteDropData {
kind: string;
type: string;
data: string | File;
}

export interface PasteDropEvent extends Record<
`${'kind' | 'type'}Map`,
Record<string, PasteDropData[]>
> {
type: string;
}

export interface PasteDropBoxProps extends Omit<
PropsWithChildren<HTMLAttributes<HTMLDivElement>>,
'onChange' | 'onDragOver' | 'onDrop' | 'onPaste'
> {
onChange?: (event: PasteDropEvent) => void;
}

export class PasteDropBox extends Component<PasteDropBoxProps> {
static async *transferData(items: DataTransferItemList) {
for (const item of items) {
const { kind, type } = item;

if (kind === 'file') {
const data = item.getAsFile();

if (data) yield { kind, type, data };
} else if (kind === 'string') {
const data = await new Promise<string>(resolve => item.getAsString(resolve));

yield { kind, type, data };
}
}
}
handlePasteDrop = async (event: ClipboardEvent | DragEvent) => {
event.preventDefault();

const items =
event.type === 'paste'
? (event as ClipboardEvent).clipboardData.items
: (event as DragEvent).dataTransfer.items;

const list = await Array.fromAsync(PasteDropBox.transferData(items));

const kindMap = groupBy(list, 'kind'),
typeMap = groupBy(list, 'type');

this.props.onChange?.({ type: event.type, kindMap, typeMap });
};

render() {
const { children, onChange, ...props } = this.props;

return (
<div
{...props}
onDragOver={event => event.preventDefault()}
onDrop={this.handlePasteDrop}
onPaste={this.handlePasteDrop}
>
{children}
</div>
);
}
}
99 changes: 99 additions & 0 deletions components/Project/ChatMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { ConsultMessage, UserRole } from '@idea2app/data-server';
import { Avatar, LinearProgress, Paper, Typography } from '@mui/material';
import { marked } from 'marked';
import { observer } from 'mobx-react';
import { ObservedComponent } from 'mobx-react-helper';

import { FileModel } from '../../models/File';
import { i18n } from '../../models/Translation';
import { FilePreview } from '../FilePreview';
import { EvaluationDisplay } from './EvaluationDisplay';

export interface ChatMessageProps extends ConsultMessage {
onFileParse?: (messageId: number, text: string) => any;
}

@observer
export class ChatMessage extends ObservedComponent<ChatMessageProps, typeof i18n> {
fileStore = new FileModel();

async componentDidMount() {
super.componentDidMount();

const { content, file } = this.props;

if (!file || content) return;

const text = await this.fileStore.getText(file);

this.props.onFileParse?.(this.props.id, text);
}

render() {
const { t } = this.observedContext,
{ project, id, content, file, evaluation, prototypes, createdAt, createdBy } = this.props;
const isBot = createdBy.roles.includes(3 as UserRole.Robot);
const avatarSrc = isBot ? '/robot-avatar.png' : createdBy?.avatar || '/default-avatar.png';
const name = isBot ? `${t('ai_assistant')} 🤖` : createdBy?.name || 'User';

return (
<div
className={`flex max-w-[95%] items-start gap-1 sm:max-w-[80%] ${isBot ? 'flex-row justify-self-start' : 'flex-row-reverse justify-self-end'}`}
>
<Avatar src={avatarSrc} alt={name} className="h-7 w-7 sm:h-8 sm:w-8" />
<Paper
elevation={1}
className="bg-primary-light text-primary-contrast rounded-[16px_16px_4px_16px] p-1.5 sm:p-2"
sx={{
backgroundColor: 'primary.light',
color: 'primary.contrastText',
}}
>
<Typography
variant="caption"
display="block"
className="mb-0.5 text-[0.7rem] opacity-80 sm:text-[0.75rem]"
>
{name}
</Typography>

{file ? (
<div className="mb-1">
<FilePreview path={file} />

{this.fileStore.downloading > 0 && (
<div className="mt-1.5">
<Typography variant="caption" className="mb-1 block text-[0.7rem] opacity-80">
{t('parsing_file_text')}
</Typography>
<LinearProgress color="inherit" />
</div>
)}
</div>
) : (
content && (
<Typography
className="prose mb-1 text-[0.875rem] sm:text-base"
variant="body2"
dangerouslySetInnerHTML={{ __html: marked(content) }}
/>
)
)}
{evaluation && (
<EvaluationDisplay
{...evaluation}
projectId={project!.id}
messageId={id}
prototypes={prototypes}
/>
)}
{createdAt && (
<Typography variant="caption" className="text-[0.65rem] opacity-60 sm:text-[0.75rem]">
{new Date(createdAt).toLocaleTimeString()}
</Typography>
)}
</Paper>
</div>
);
}
}
32 changes: 32 additions & 0 deletions models/File.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { SignedLink } from '@idea2app/data-server';
import { BaseModel, toggle } from 'mobx-restful';

import userStore from './User';

export class FileModel extends BaseModel {
baseURI = 'file';
client = userStore.client;

@toggle('uploading')
async upload(file: File | Blob) {
const name = file instanceof File ? file.name : crypto.randomUUID();

const { body } = await this.client.post<SignedLink>(`${this.baseURI}/signed-link/${name}`);

await this.client.put(body!.putLink, file, { 'Content-Type': file.type });

return body!.getLink;
}

@toggle('downloading')
async getText(URI: string) {
const { pathname } = new URL(URI);

const { body } = await this.client.get<string>(`${this.baseURI}/${pathname}`, {
Accept: 'text/*',
});
return body!;
}
}

export default new FileModel();
61 changes: 30 additions & 31 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,74 +12,73 @@
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.14.1",
"@giscus/react": "^3.1.0",
"@koa/bodyparser": "^6.0.0",
"@mui/lab": "^7.0.1-beta.21",
"@mui/material": "^7.3.7",
"@mui/material-nextjs": "^7.3.7",
"@passwordless-id/webauthn": "^2.3.1",
"@sentry/nextjs": "^10.35.0",
"@koa/bodyparser": "^6.1.0",
"@mui/lab": "^7.0.1-beta.23",
"@mui/material": "^7.3.9",
"@mui/material-nextjs": "^7.3.9",
"@passwordless-id/webauthn": "^2.3.5",
"@sentry/nextjs": "^10.42.0",
"file-type": "^21.3.0",
"idb-keyval": "^6.2.2",
"jsonwebtoken": "^9.0.3",
"koa": "^3.1.1",
"koa": "^3.1.2",
"koa-jwt": "^4.0.4",
"koajax": "^3.1.2",
"lodash.debounce": "^4.0.8",
"marked": "^17.0.1",
"marked": "^17.0.4",
"mime": "^4.1.0",
"mobx": "^6.15.0",
"mobx-github": "^0.6.2",
"mobx-i18n": "^0.7.2",
"mobx-lark": "^2.6.4",
"mobx-lark": "^2.7.0",
"mobx-react": "^9.2.1",
"mobx-react-helper": "^0.5.1",
"mobx-restful": "^2.1.4",
"next": "^16.1.4",
"next": "^16.1.6",
"next-pwa": "~5.6.0",
"next-ssr-middleware": "^1.1.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"web-utility": "^4.6.4",
"webpack": "^5.104.1"
"webpack": "^5.105.4"
},
"devDependencies": {
"@babel/plugin-proposal-decorators": "^7.28.6",
"@babel/plugin-proposal-decorators": "^7.29.0",
"@babel/plugin-transform-typescript": "^7.28.6",
"@babel/preset-react": "^7.28.5",
"@cspell/eslint-plugin": "^9.6.0",
"@eslint/js": "^9.39.2",
"@idea2app/data-server": "^1.0.0-rc.9",
"@next/eslint-plugin-next": "^16.1.4",
"@stylistic/eslint-plugin": "^5.7.0",
"@tailwindcss/postcss": "^4.1.18",
"@cspell/eslint-plugin": "^9.7.0",
"@eslint/js": "^10.0.1",
"@idea2app/data-server": "^1.0.0-rc.10",
"@next/eslint-plugin-next": "^16.1.6",
"@stylistic/eslint-plugin": "^5.10.0",
"@tailwindcss/postcss": "^4.2.1",
"@tailwindcss/typography": "^0.5.19",
"@types/eslint-config-prettier": "^6.11.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/koa": "^3.0.1",
"@types/lodash.debounce": "^4.0.9",
"@types/next-pwa": "^5.6.9",
"@types/node": "^24.10.9",
"@types/react": "^19.2.8",
"eslint": "^9.39.2",
"eslint-config-next": "^16.1.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"eslint": "^10.0.3",
"eslint-config-next": "^16.1.6",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-simple-import-sort": "^12.1.1",
"git-utility": "^0.3.0",
"globals": "^17.0.0",
"globals": "^17.4.0",
"husky": "^9.1.7",
"jiti": "^2.6.1",
"lint-staged": "^16.2.7",
"postcss": "^8.5.6",
"prettier": "^3.8.0",
"lint-staged": "^16.3.2",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"prettier-plugin-css-order": "^2.2.0",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.1.18",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.53.1"
"typescript-eslint": "^8.56.1"
},
"resolutions": {
"mobx-github": "$mobx-github",
"next": "$next"
},
"pnpm": {
Expand Down
Loading
Loading