Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ jobs:
packages: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7

- name: Inject Environment variables
run: |
Expand All @@ -24,18 +24,18 @@ jobs:
EOF
cp .env.local .env.personal.local

- uses: docker/login-action@v3.6.0
- uses: docker/login-action@v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}

- uses: docker/metadata-action@v5.10.0
- uses: docker/metadata-action@v6.1.0
id: meta
with:
images: ghcr.io/${{ github.repository }}

- uses: docker/build-push-action@v6.18.0
- uses: docker/build-push-action@v7.2.0
id: push
with:
context: .
Expand Down
48 changes: 9 additions & 39 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,51 +10,21 @@ jobs:
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
if: ${{ env.VERCEL_TOKEN && env.VERCEL_ORG_ID && env.VERCEL_PROJECT_ID }}

- uses: actions/setup-node@v6
if: ${{ env.VERCEL_TOKEN && env.VERCEL_ORG_ID && env.VERCEL_PROJECT_ID }}
with:
node-version: 24

- name: Deploy to Vercel
id: vercel-deployment
uses: amondnet/vercel-action@v42.3.0
if: ${{ env.VERCEL_TOKEN && env.VERCEL_ORG_ID && env.VERCEL_PROJECT_ID }}
shell: bash
run: |
set -euo pipefail

npm install vercel -g

if [[ "$GITHUB_REF" == 'refs/heads/main' ]]; then
DeployOutput=$(vercel -t "$VERCEL_TOKEN" --prod)
else
DeployOutput=$(vercel -t "$VERCEL_TOKEN")
fi
echo "$DeployOutput"

ParsedURL=$(echo "$DeployOutput" | grep -Eo 'https://[^[:space:]]*\.vercel\.app' | tail -n 1)

if [[ -z "$ParsedURL" ]]; then
echo "Failed to parse Vercel URL from deploy output"
exit 1
fi
vercel inspect "$ParsedURL" -t "$VERCEL_TOKEN" -F json > vercel-inspect.json

InspectURL=$(jq -r '.url // empty' vercel-inspect.json)

if [[ -z "$InspectURL" ]]; then
echo "Failed to parse inspect url from vercel-inspect.json"
exit 1
fi
if [[ "$InspectURL" != http* ]]; then
InspectURL="https://$InspectURL"
fi
echo "preview-url=$InspectURL" >> "$GITHUB_OUTPUT"
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
working-directory: ./
vercel-args: ${{ github.ref == 'refs/heads/main' && '--prod' || '' }}

- name: Lark notification
uses: Open-Source-Bazaar/feishu-action@v3
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:22-slim AS base
FROM node:24-slim AS base
RUN apt-get update && \
apt-get install ca-certificates curl libjemalloc-dev -y --no-install-recommends && \
rm -rf /var/lib/apt/lists/*
Expand Down
22 changes: 12 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,16 +65,17 @@ You can check out [the Next.js GitHub repository][27] - your feedback and contri

### Environment variables

| name | file | description |
| :----------------------: | :----------: | :---------------------: |
| `JWT_SECRET` | `.env.local` | [API authorization][28] |
| `SENTRY_AUTH_TOKEN` | `.env.local` | [Official document][29] |
| `SENTRY_ORG` | `.env` | [Official document][30] |
| `SENTRY_PROJECT` | `.env` | [Official document][30] |
| `NEXT_PUBLIC_SENTRY_DSN` | `.env` | [Official document][31] |
| `GITHUB_TOKEN` | `.env.local` | [Official document][32] |
| `LARK_APP_ID` | `.env.local` | [Official document][33] |
| `LARK_APP_SECRET` | `.env.local` | [Official document][33] |
| name | file | description |
| :----------------------------: | :----------: | :----------------------------: |
| `JWT_SECRET` | `.env.local` | [API authorization][28] |
| `SENTRY_AUTH_TOKEN` | `.env.local` | [Official document][29] |
| `SENTRY_ORG` | `.env` | [Official document][30] |
| `SENTRY_PROJECT` | `.env` | [Official document][30] |
| `NEXT_PUBLIC_SENTRY_DSN` | `.env` | [Official document][31] |
| `GITHUB_TOKEN` | `.env.local` | [Official document][32] |
| `LARK_APP_ID` | `.env.local` | [Official document][33] |
| `LARK_APP_SECRET` | `.env.local` | [Official document][33] |
| `GOOGLE_GENERATIVE_AI_API_KEY` | `.env.local` | [Google Gemini API secret][35] |

### Vercel

Expand Down Expand Up @@ -123,3 +124,4 @@ pnpm container
[32]: https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api#authenticating-with-a-personal-access-token
[33]: https://open.larksuite.com/document/server-docs/getting-started/api-access-token/app-access-token-development-guide#95c7f5f5
[34]: https://nextjs.org/docs/deployment
[35]: https://ai.google.dev/gemini-api/docs/api-key
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';
2 changes: 1 addition & 1 deletion components/Layout/menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const PublicMenu = ({ t }: typeof i18n) => [
{ title: t('latest_projects'), href: '/project' },
{ title: t('member'), href: '/member' },
{ title: t('open_source_project'), href: '/open-source' },
{ title: t('wiki'), href: 'https://wiki.idea2.app' },
{ title: t('wiki'), href: '/wiki' },
{ title: t('github_reward'), href: '/project/reward/issue', target: '_top' },
];

Expand Down
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>
);
}
}
101 changes: 101 additions & 0 deletions components/Project/ChatMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
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, I18nContext } 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> {
static contextType = I18nContext;

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 ? 'mr-auto flex-row' : 'ml-auto flex-row-reverse'}`}
>
<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>
);
}
}
Loading
Loading