diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..0eeaf3b --- /dev/null +++ b/.env.development @@ -0,0 +1 @@ +NEXT_PUBLIC_API_HOST = http://localhost:8080 diff --git a/components/Project/EvaluationDisplay.tsx b/components/Project/EvaluationDisplay.tsx index 1dd17df..dfd63f3 100644 --- a/components/Project/EvaluationDisplay.tsx +++ b/components/Project/EvaluationDisplay.tsx @@ -1,27 +1,53 @@ -import { RequirementEvaluation, UserRole } from '@idea2app/data-server'; +import { + PrototypeType, + PrototypeVersion, + RequirementEvaluation, + UserRole, +} from '@idea2app/data-server'; import { Box, Typography } from '@mui/material'; import { observer } from 'mobx-react'; import { FC, useContext } from 'react'; -import { I18nContext } from '../../models/Translation'; +import { i18n, I18nContext } from '../../models/Translation'; import userStore from '../../models/User'; +import { PrototypeGenerator, PrototypeGeneratorProps } from './PrototypeGenerator'; -export const EvaluationDisplay: FC = observer( +export const DevelopmentScopeName = ({ t }: typeof i18n) => [ + t('product_prototype'), + t('ui_design'), + t('desktop'), + t('mobile'), + t('server'), +]; + +export interface EvaluationDisplayProps + extends RequirementEvaluation, + Pick { + prototypes?: PrototypeVersion[]; +} + +export const EvaluationDisplay: FC = observer( ({ title, scopes = [], + models, developerCount, designerCount, workload, monthPeriod, budget, factor, + projectId, + messageId, + prototypes, }) => { - const { t } = useContext(I18nContext), + const i18n = useContext(I18nContext); + const { t } = i18n, { roles } = userStore.session || {}; return ( = observer( {t('development_scopes')} - {scopes.map(scope => ( - - {scope} + {scopes.map(scope => { + const prototypeType = ( + scope === 2 ? 'desktop' : scope === 3 ? 'mobile' : undefined + ) as PrototypeType; + + return ( + + {DevelopmentScopeName(i18n)[scope]} + + {prototypeType && ( + type === prototypeType)} + /> + )} + + ); + })} + + + )} + {models?.[0] && ( + + + {t('feature_modules')} + + + {models.map((model, index) => ( + + {model} ))} diff --git a/components/Project/NewCard.tsx b/components/Project/NewCard.tsx index 5ec7ded..96dee70 100644 --- a/components/Project/NewCard.tsx +++ b/components/Project/NewCard.tsx @@ -5,8 +5,36 @@ import Link from 'next/link'; import { FC, useContext } from 'react'; import { I18nContext } from '../../models/Translation'; +import type zhCN from '../../translation/zh-CN'; -export const ProjectCard: FC = observer(({ id, name, projectStatus }) => { +const statusTextKeys: (keyof typeof zhCN)[] = [ + 'project_open', // Open + 'project_evaluated', // Evaluated + 'project_contract_generated', // ContractGenerated + 'project_in_development', // InDevelopment + 'project_in_testing', // InTesting + 'project_maintenance', // Maintenance +]; + +const bgColors: string[] = [ + 'grey.200', // Open + 'success.light', // Evaluated + 'warning.light', // ContractGenerated + 'info.light', // InDevelopment + 'secondary.light', // InTesting + 'primary.light', // Maintenance +]; + +const textColors: string[] = [ + 'text.primary', // Open + 'success.contrastText', // Evaluated + 'warning.contrastText', // ContractGenerated + 'info.contrastText', // InDevelopment + 'secondary.contrastText', // InTesting + 'primary.contrastText', // Maintenance +]; + +export const ProjectCard: FC = observer(({ id, name, status = 0 }) => { const { t } = useContext(I18nContext); return ( @@ -21,25 +49,11 @@ export const ProjectCard: FC = observer(({ id, name, projectStatus }) = px: 1, py: 0.5, borderRadius: 1, - bgcolor: - projectStatus === '1' - ? 'success.light' - : projectStatus === '0' - ? 'grey.200' - : 'warning.light', - color: - projectStatus === '1' - ? 'success.contrastText' - : projectStatus === '0' - ? 'text.primary' - : 'warning.contrastText', + bgcolor: bgColors[status] ?? 'grey.200', + color: textColors[status] ?? 'text.primary', }} > - {projectStatus === '1' - ? t('project_open') - : projectStatus === '0' - ? t('project_closed') - : t('project_pending')} + {t((statusTextKeys[status] ?? 'project_open') as keyof typeof zhCN)} diff --git a/components/Project/PrototypeGenerator.tsx b/components/Project/PrototypeGenerator.tsx new file mode 100644 index 0000000..08727fa --- /dev/null +++ b/components/Project/PrototypeGenerator.tsx @@ -0,0 +1,170 @@ +import { PrototypeType, PrototypeVersion } from '@idea2app/data-server'; +import { Box, Button, CircularProgress, Link, Typography } from '@mui/material'; +import { observable } from 'mobx'; +import { observer } from 'mobx-react'; +import { ObservedComponent } from 'mobx-react-helper'; +import { createRef } from 'react'; +import { inViewport, sleep } from 'web-utility'; + +import { PrototypeVersionModel } from '../../models/PrototypeVersion'; +import { i18n, I18nContext } from '../../models/Translation'; + +export interface PrototypeGeneratorProps { + projectId: number; + messageId: number; + type: PrototypeType; + prototype?: PrototypeVersion; +} + +@observer +export class PrototypeGenerator extends ObservedComponent { + static contextType = I18nContext; + + versionStore = new PrototypeVersionModel(this.props.projectId, this.props.type); + + @observable + accessor version = this.props.prototype; + + private root = createRef(); + + componentDidMount() { + super.componentDidMount(); + + this.pollStatusCheck(); + } + + async pollStatusCheck() { + const { props, version } = this, + rootElement = this.root.current; + + while (version?.status === 'pending' || version?.status === 'processing') { + if (!rootElement?.isConnected) break; + + if (inViewport(rootElement)) + this.version = await this.versionStore.getOne(props.prototype!.id); + + await sleep(3); + } + } + + handleGenerateClick = async () => { + this.version = await this.versionStore.updateOne({ + evaluationMessage: this.props.messageId, + }); + + return this.pollStatusCheck(); + }; + + renderPending() { + const { t } = this.observedContext; + const loading = this.versionStore.uploading > 0; + + return ( + + ); + } + + renderGenerating() { + const { t } = this.observedContext; + + return ( + + + {t('prototype_generating')} + + ); + } + + renderCompleted() { + const { t } = this.observedContext; + const { previewLink, gitLogsLink } = this.version || {}; + + return ( + + {previewLink && ( + + {t('view_preview')} + + )} + {gitLogsLink && ( + + {t('view_ai_log')} + + )} + + ); + } + + renderFailed() { + const { t } = this.observedContext; + const { errorMessage, gitLogsLink } = this.version || {}; + + return ( + + + {errorMessage || t('prototype_generation_failed')} + + {gitLogsLink && ( + + {t('view_ai_log')} + + )} + + ); + } + + render() { + const { version } = this; + + return ( + + {!version || version.status === 'pending' + ? this.renderPending() + : version.status === 'processing' + ? this.renderGenerating() + : version.status === 'completed' + ? this.renderCompleted() + : this.renderFailed()} + + ); + } +} diff --git a/components/Project/Card.tsx b/components/Project/PublicCard.tsx similarity index 100% rename from components/Project/Card.tsx rename to components/Project/PublicCard.tsx diff --git a/components/Project/index.tsx b/components/Project/index.tsx index f551684..2e0c018 100644 --- a/components/Project/index.tsx +++ b/components/Project/index.tsx @@ -1,7 +1,7 @@ import { FC } from 'react'; import { Project } from '../../models/Project'; -import { ProjectCard } from './Card'; +import { ProjectCard } from './PublicCard'; export interface ProjectListLayoutProps { defaultData: Project[]; diff --git a/components/ScrollBoundary.tsx b/components/ScrollBoundary.tsx index 13a351c..a044337 100644 --- a/components/ScrollBoundary.tsx +++ b/components/ScrollBoundary.tsx @@ -11,17 +11,25 @@ export type ScrollBoundaryProps = PropsWithChildren< } >; -function touch(edge: EdgePosition, onTouch: TouchHandler) { - return (node: HTMLElement | null) => { - if (node) { - new IntersectionObserver(([{ isIntersecting }]) => { - if (isIntersecting) { - onTouch(edge); - } - }).observe(node); - } - }; -} +const EdgeOrder: EdgePosition[] = ['top', 'right', 'bottom', 'left']; + +const touch = (edge: EdgePosition, onTouch: TouchHandler) => (node: HTMLElement | null) => { + if (!node) return; + + const anchor = node.parentElement?.parentElement; + + const { overflowX, overflowY } = anchor ? getComputedStyle(anchor) : {}; + + const root = `${overflowX}${overflowY}`.match(/auto|scroll/) ? anchor : null; + + const edgeMargins = Array(4).fill('0px'); + edgeMargins[EdgeOrder.indexOf(edge)] = '200px'; + + new IntersectionObserver(([{ isIntersecting }]) => isIntersecting && onTouch(edge), { + root, + rootMargin: edgeMargins.join(' '), + }).observe(node); +}; export const ScrollBoundary: FC = ({ className = '', @@ -30,7 +38,7 @@ export const ScrollBoundary: FC = ({ left, right, bottom, - children + children, }) => (
{
- - -

{title}

- {children} - (this.modalShown = false)} - > - window.location.reload()} /> - + + + (this.modalShown = false)} /> + +
); diff --git a/components/User/SessionForm.tsx b/components/User/SessionForm.tsx index bfc7b8e..d9a6e6b 100644 --- a/components/User/SessionForm.tsx +++ b/components/User/SessionForm.tsx @@ -1,5 +1,5 @@ -import { PhoneSignInData } from '@idea2app/data-server'; -import { Button, IconButton, InputAdornment, Tab, Tabs, TextField } from '@mui/material'; +import { SignInData } from '@idea2app/data-server'; +import { Button, IconButton, Tab, Tabs, TextField } from '@mui/material'; import { observable } from 'mobx'; import { observer } from 'mobx-react'; import { ObservedComponent } from 'mobx-react-helper'; @@ -11,7 +11,7 @@ import userStore from '../../models/User'; import { SymbolIcon } from '../Icon'; export interface SessionFormProps { - onSignIn?: (data?: PhoneSignInData) => any; + onSignIn?: (data?: SignInData) => any; } @observer @@ -28,34 +28,48 @@ export class SessionForm extends ObservedComponent(event.currentTarget.form!); + const { email } = formToJSON(event.currentTarget.form!); - if (!mobilePhone) throw new Error(t('phone_required_for_webauthn')); + if (!email) throw new Error(t('email_required_for_webauthn')); - await userStore.signUpWebAuthn(mobilePhone); + await userStore.signUpWebAuthn(email); } else { await userStore.signInWebAuthn(); } this.props.onSignIn?.(); }; + handleEmailOTP = async (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const { t } = this.observedContext; + const { email } = formToJSON(event.currentTarget.form!); + + if (!email) throw new URIError(t('email_required_for_OTP')); + + await userStore.sendOTP(email); + + alert(t('OTP_sent_to_email')); + }; + handleSubmit = async (event: FormEvent) => { event.preventDefault(); event.stopPropagation(); const { t } = this.observedContext; - const { mobilePhone, password } = formToJSON(event.currentTarget); + const { email, password } = formToJSON(event.currentTarget); if (this.signType === 'up') { - await userStore.signUp(mobilePhone, password); + await userStore.signUp(email, password); this.signType = 'in'; alert(t('registration_success_please_login')); } else { - await userStore.signIn(mobilePhone, password); + await userStore.signIn(email, password); - this.props.onSignIn?.({ mobilePhone, password }); + this.props.onSignIn?.({ email, password }); } }; @@ -78,22 +92,13 @@ export class SessionForm extends ObservedComponent +86, - }, - }} + label={t('email')} + placeholder={t('please_enter_email')} />
- + {signType === 'in' && ( + + + + )} diff --git a/components/VersionComparison.tsx b/components/VersionComparison.tsx index 45ad956..988cee2 100644 --- a/components/VersionComparison.tsx +++ b/components/VersionComparison.tsx @@ -17,8 +17,8 @@ export const VersionComparison: FC = observer(() => { {t('github_one_click_login')} - {t('free_evaluation_daily_limit')} {t('submitted_data_public')} + {t('free_evaluation_daily_limit')} {t('volunteer_community_support')} {t('open_source_bounty_development')} @@ -33,9 +33,10 @@ export const VersionComparison: FC = observer(() => { {t('commercial_version')} - {t('phone_one_click_register')} - {t('unlimited_evaluation_24_7')} + {t('email_one_click_register')} {t('project_data_confidential')} + {t('unlimited_evaluation_24_7')} + {t('ai_interactive_prototype')} {t('daily_engineer_review')} {t('professional_development_team')} diff --git a/models/PrototypeVersion.ts b/models/PrototypeVersion.ts new file mode 100644 index 0000000..673fbf3 --- /dev/null +++ b/models/PrototypeVersion.ts @@ -0,0 +1,17 @@ +import { PrototypeType, PrototypeVersion } from '@idea2app/data-server'; + +import { TableModel } from './Base'; +import userStore from './User'; + +export class PrototypeVersionModel extends TableModel { + baseURI = ''; + client = userStore.client; + + constructor( + public projectId: number, + public type: PrototypeType, + ) { + super(); + this.baseURI = `project/${projectId}/prototype/${type}/version`; + } +} diff --git a/models/User.ts b/models/User.ts index a90e537..7925a80 100644 --- a/models/User.ts +++ b/models/User.ts @@ -21,17 +21,21 @@ export class UserModel extends TableModel { ); restored = !isServer() && restore(this, 'User'); - client = new HTTPClient({ baseURI: API_HOST, responseType: 'json' }).use(({ request }, next) => { - const isSameDomain = API_HOST.startsWith(new URL(request.path, API_HOST).origin); + client = new HTTPClient({ baseURI: API_HOST, responseType: 'json' }).use( + async ({ request }, next) => { + await this.restored; - if (isSameDomain && this.session) - request.headers = { - ...request.headers, - Authorization: `Bearer ${this.session.token}`, - }; + const isSameDomain = API_HOST.startsWith(new URL(request.path, API_HOST).origin); - return next(); - }); + if (isSameDomain && this.session) + request.headers = { + ...request.headers, + Authorization: `Bearer ${this.session.token}`, + }; + + return next(); + }, + ); @toggle('uploading') async sendOTP(address: string) { diff --git a/package.json b/package.json index 50ac234..c6b66e8 100644 --- a/package.json +++ b/package.json @@ -17,52 +17,52 @@ "@mui/material": "^7.3.4", "@mui/material-nextjs": "^7.3.3", "@passwordless-id/webauthn": "^2.3.1", - "@sentry/nextjs": "^10.19.0", + "@sentry/nextjs": "^10.21.0", "file-type": "^21.0.0", "idb-keyval": "^6.2.2", "jsonwebtoken": "^9.0.2", - "koa": "^3.0.1", + "koa": "^3.0.3", "koa-jwt": "^4.0.4", "koajax": "^3.1.2", "lodash.debounce": "^4.0.8", - "marked": "^16.4.0", + "marked": "^16.4.1", "mime": "^4.1.0", "mobx": "^6.15.0", "mobx-github": "^0.6.0", "mobx-i18n": "^0.7.2", - "mobx-lark": "^2.4.3", + "mobx-lark": "^2.5.0", "mobx-react": "^9.2.1", "mobx-react-helper": "^0.5.1", - "mobx-restful": "^2.1.3", - "next": "^15.5.5", + "mobx-restful": "^2.1.4", + "next": "^15.5.6", "next-pwa": "~5.6.0", "next-ssr-middleware": "^1.0.3", "react": "^19.2.0", "react-dom": "^19.2.0", - "web-utility": "^4.6.2", + "web-utility": "^4.6.3", "webpack": "^5.102.1" }, "devDependencies": { "@babel/plugin-proposal-decorators": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-react": "^7.27.1", - "@cspell/eslint-plugin": "^9.2.1", + "@cspell/eslint-plugin": "^9.2.2", "@eslint/compat": "^1.4.0", - "@eslint/js": "^9.37.0", - "@idea2app/data-server": "1.0.0-rc.1", - "@next/eslint-plugin-next": "^15.5.5", - "@stylistic/eslint-plugin": "^5.4.0", - "@tailwindcss/postcss": "^4.1.14", + "@eslint/js": "^9.38.0", + "@idea2app/data-server": "^1.0.0-rc.3", + "@next/eslint-plugin-next": "^15.5.6", + "@stylistic/eslint-plugin": "^5.5.0", + "@tailwindcss/postcss": "^4.1.15", "@tailwindcss/typography": "^0.5.19", "@types/eslint-config-prettier": "^6.11.3", "@types/jsonwebtoken": "^9.0.10", "@types/koa": "^3.0.0", "@types/lodash.debounce": "^4.0.9", "@types/next-pwa": "^5.6.9", - "@types/node": "^22.18.10", + "@types/node": "^22.18.12", "@types/react": "^19.2.2", - "eslint": "^9.37.0", - "eslint-config-next": "^15.5.5", + "eslint": "^9.38.0", + "eslint-config-next": "^15.5.6", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react": "^7.37.5", "eslint-plugin-simple-import-sort": "^12.1.1", @@ -70,14 +70,14 @@ "globals": "^16.4.0", "husky": "^9.1.7", "jiti": "^2.6.1", - "lint-staged": "^16.2.4", + "lint-staged": "^16.2.6", "postcss": "^8.5.6", "prettier": "^3.6.2", "prettier-plugin-css-order": "^2.1.2", - "prettier-plugin-tailwindcss": "^0.6.14", - "tailwindcss": "^4.1.14", + "prettier-plugin-tailwindcss": "^0.7.1", + "tailwindcss": "^4.1.15", "typescript": "~5.9.3", - "typescript-eslint": "^8.46.1" + "typescript-eslint": "^8.46.2" }, "resolutions": { "mobx-github": "$mobx-github", diff --git a/pages/_document.tsx b/pages/_document.tsx index 50f067a..45ecc63 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -74,7 +74,7 @@ export default class CustomDocument extends Document { * */} diff --git a/pages/dashboard/index.tsx b/pages/dashboard/index.tsx index 5290a89..283c12a 100644 --- a/pages/dashboard/index.tsx +++ b/pages/dashboard/index.tsx @@ -1,9 +1,9 @@ import { User, UserRole } from '@idea2app/data-server'; -import { Container, Grid, Typography } from '@mui/material'; +import { Box, Button, Container, Grid, TextField, Typography } from '@mui/material'; import { observer } from 'mobx-react'; -import { useRouter } from 'next/router'; -import { compose, JWTProps, jwtVerifier } from 'next-ssr-middleware'; -import { FC, useContext } from 'react'; +import { compose, JWTProps, jwtVerifier, RouteProps, router } from 'next-ssr-middleware'; +import { FC, FormEvent, useContext } from 'react'; +import { formToJSON } from 'web-utility'; import { ProjectCard } from '../../components/Project/NewCard'; import { ScrollList } from '../../components/ScrollList'; @@ -11,27 +11,60 @@ import { SessionBox } from '../../components/User/SessionBox'; import { ProjectModel } from '../../models/ProjectEvaluation'; import { I18nContext } from '../../models/Translation'; -type DashboardPageProps = JWTProps; +type DashboardPageProps = RouteProps & JWTProps; -export const getServerSideProps = compose<{}, DashboardPageProps>(jwtVerifier()); +export const getServerSideProps = compose<{}, DashboardPageProps>(router, jwtVerifier()); // Initialize project store for client-side rendering const projectStore = new ProjectModel(); -const DashboardPage: FC = observer(({ jwtPayload }) => { - const { asPath } = useRouter(); +const DashboardPage: FC = observer(({ route, jwtPayload }) => { const i18n = useContext(I18nContext); const { t } = i18n; const menu = [{ href: '/dashboard', title: t('overview') }]; + const handleCreateProject = async (event: FormEvent) => { + event.preventDefault(); + event.stopPropagation(); + + const { name } = formToJSON<{ name: string }>(event.currentTarget); + + const { id } = await projectStore.updateOne({ name }); + + if (id) location.href = `/dashboard/project/${id}`; + }; + return ( - + {t('welcome_use')} + + + + + {t('recent_projects')} diff --git a/pages/dashboard/project/[id].tsx b/pages/dashboard/project/[id].tsx index e1906c7..0e42cd2 100644 --- a/pages/dashboard/project/[id].tsx +++ b/pages/dashboard/project/[id].tsx @@ -1,7 +1,8 @@ import { ConsultMessage, User, UserRole } from '@idea2app/data-server'; import { Avatar, Box, Button, Container, Paper, TextField, Typography } from '@mui/material'; +import { marked } from 'marked'; import { observer } from 'mobx-react'; -import { ObservedComponent } from 'mobx-react-helper'; +import { ObservedComponent, reaction } from 'mobx-react-helper'; import { compose, JWTProps, jwtVerifier, RouteProps, router } from 'next-ssr-middleware'; import { FormEvent } from 'react'; import { formToJSON, scrollTo, sleep } from 'web-utility'; @@ -40,13 +41,24 @@ export default class ProjectEvaluationPage extends ObservedComponent< } componentDidMount() { + super.componentDidMount(); + this.projectStore.getOne(this.projectId); } + @reaction(({ messageStore }) => messageStore.allItems) + async handleMessageChange() { + await sleep(); + + scrollTo('#last-message'); + } + handleMessageSubmit = async (event: FormEvent) => { event.preventDefault(); - let { content } = formToJSON<{ content: string }>(event.currentTarget); + const form = event.currentTarget; + + let { content } = formToJSON<{ content: string }>(form); content = content.trim(); @@ -54,22 +66,18 @@ export default class ProjectEvaluationPage extends ObservedComponent< await this.messageStore.updateOne({ content }); - event.currentTarget.reset(); - - await sleep(0.2); - - scrollTo('#last-message'); + form.reset(); }; renderChatMessage = ( - { id, content, evaluation, createdAt, createdBy }: ConsultMessage, + { id, content, evaluation, prototypes, createdAt, createdBy }: ConsultMessage, index = 0, { length }: ConsultMessage[], ) => { const { t } = this.observedContext; 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'; + const name = isBot ? `${t('ai_assistant')} 🤖` : createdBy?.name || 'User'; return ( @@ -106,12 +114,21 @@ export default class ProjectEvaluationPage extends ObservedComponent< {content && ( - - {content} - + + )} + {evaluation && ( + )} - {evaluation && } - {createdAt && ( {new Date(createdAt).toLocaleTimeString()} @@ -135,28 +152,20 @@ export default class ProjectEvaluationPage extends ObservedComponent< - - - {title} - - + +

{title}

{/* Chat Messages Area */} - + ( - {allItems[0] ? ( - allItems.map(this.renderChatMessage) - ) : ( - - - {t('loading_project_evaluation')} - - - )} + {allItems.map(this.renderChatMessage)} )} /> @@ -182,6 +191,7 @@ export default class ProjectEvaluationPage extends ObservedComponent< />