├── .dockerignore ├── .github └── workflows │ ├── ci.yml │ └── docker.yml ├── .gitignore ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── build ├── babel.ts ├── rules.ts ├── webpack.config.csr.ts └── webpack.config.ssr.ts ├── development-note.md ├── docs └── docker-compose.yml ├── interface ├── Api │ ├── Form │ │ └── index.ts │ └── Response │ │ └── index.ts ├── Form │ ├── ImageView │ │ └── index.d.ts │ ├── MultiSelection │ │ └── index.d.ts │ ├── ShortAnswer │ │ └── index.d.ts │ ├── SingleSelection │ │ └── index.d.ts │ ├── TextField │ │ └── index.d.ts │ └── index.d.ts ├── Response │ └── index.d.ts └── index.d.ts ├── package.json ├── schemas ├── Api │ ├── Form │ │ ├── NewForm.json │ │ └── UpdateForm.json │ └── Response │ │ └── NewResponse.json ├── FormTemplate.json ├── ImageView │ └── ImageViewTemplate.json ├── MultiSelection │ ├── MultiSelectionChoice.json │ └── MultiSelectionTemplate.json ├── ShortAnswer │ └── ShortAnswerTemplate.json ├── SingleSelection │ ├── SingleSelectionChoice.json │ └── SingleSelectionTemplate.json ├── TextField │ └── TextFieldTemplate.json └── test │ └── FormTemplate.json ├── server ├── index.ts ├── logger.ts ├── middlewares │ ├── api │ │ ├── form.ts │ │ ├── index.ts │ │ └── response.ts │ ├── cors.ts │ ├── frontend.ts │ ├── index.ts │ └── logger.ts ├── models │ ├── database.ts │ ├── developmentDB.json │ ├── form.ts │ └── response.ts ├── schemas.ts └── tsconfig.json ├── test ├── makeTestData.ts └── process.ts ├── tools └── loadable │ ├── babel.js │ ├── index.js │ └── webpack.js ├── tsconfig.json ├── view ├── api │ ├── ApiError.ts │ ├── Form.ts │ ├── NetworkError.ts │ └── Response.ts ├── blog │ ├── create-form.png │ └── index.tsx ├── components │ ├── Activity.tsx │ ├── Alert.tsx │ ├── AppBar │ │ ├── AppBarButton.tsx │ │ ├── AppBarIcon.tsx │ │ ├── AppBarIconButton.tsx │ │ ├── AppBarTitle.tsx │ │ └── index.tsx │ ├── Asterisk.tsx │ ├── Button.tsx │ ├── Checkbox.tsx │ ├── Container.tsx │ ├── Dialog.tsx │ ├── DraggableList │ │ ├── MoveContainer.tsx │ │ ├── TemplateContainer.tsx │ │ └── index.tsx │ ├── Form │ │ ├── Description.tsx │ │ ├── Editor.tsx │ │ ├── EditorItem.tsx │ │ ├── ErrorMesssage.tsx │ │ ├── FloatToolbar.tsx │ │ ├── FormContainer.tsx │ │ ├── FormItem.tsx │ │ ├── ImageView │ │ │ ├── Editor.tsx │ │ │ └── index.tsx │ │ ├── Legend.tsx │ │ ├── LegendEditor.tsx │ │ ├── MultiSelection │ │ │ ├── Editor.tsx │ │ │ └── index.tsx │ │ ├── PageTitle │ │ │ ├── Editor.tsx │ │ │ └── index.tsx │ │ ├── Selection.tsx │ │ ├── SelectionEditor.tsx │ │ ├── ShortAnswer │ │ │ ├── Editor.tsx │ │ │ └── index.tsx │ │ ├── SingleSelection │ │ │ ├── Editor.tsx │ │ │ └── index.tsx │ │ ├── SubmitButton.tsx │ │ ├── TextArea.tsx │ │ ├── TextField │ │ │ ├── Editor.tsx │ │ │ └── index.tsx │ │ ├── TextInput.tsx │ │ ├── TypeSelect.tsx │ │ └── index.tsx │ ├── FormAvatar.tsx │ ├── FormCard.tsx │ ├── Hover.tsx │ ├── Indicator.tsx │ ├── LoadingIndicator.tsx │ ├── NoSSR.tsx │ ├── Radio.tsx │ ├── ResponsivePanel.tsx │ ├── Select.tsx │ ├── SiteAppBar.tsx │ ├── SiteFooter.tsx │ ├── Status.tsx │ ├── Switch.tsx │ ├── Tabs │ │ └── index.tsx │ ├── TextFooter.tsx │ ├── ToolBar │ │ ├── ToolBar.tsx │ │ ├── ToolBarButton.tsx │ │ ├── ToolBarIcon.tsx │ │ ├── ToolBarIconButton.tsx │ │ ├── ToolBarSeparator.tsx │ │ ├── ToolBarSpring.tsx │ │ └── ToolBarText.tsx │ ├── Tooltip.tsx │ ├── Writing.tsx │ └── icons │ │ ├── Add.tsx │ │ ├── AddCircle.tsx │ │ ├── Back.tsx │ │ ├── Clear.tsx │ │ ├── CreateNewFolder.tsx │ │ ├── Delete.tsx │ │ ├── Drag.tsx │ │ ├── ErrorCircle.tsx │ │ ├── Folder.tsx │ │ ├── GoogleFormLogo.tsx │ │ ├── IconButton.tsx │ │ ├── Left.tsx │ │ ├── Lock.tsx │ │ ├── LockOpen.tsx │ │ ├── Menu.tsx │ │ ├── NotFound.tsx │ │ ├── Photo.tsx │ │ ├── Right.tsx │ │ ├── Search.tsx │ │ ├── Settings.tsx │ │ ├── Share.tsx │ │ ├── SvgIcon.tsx │ │ ├── Text.tsx │ │ ├── Warning.tsx │ │ └── WifiOff.tsx ├── constants.ts ├── containers │ ├── App │ │ ├── Helmet.tsx │ │ └── index.tsx │ ├── BetaDialog │ │ ├── index.tsx │ │ └── withBetaDialog.tsx │ ├── BlogListPage │ │ ├── Loadable.tsx │ │ └── index.tsx │ ├── BlogPage │ │ ├── Content │ │ │ └── index.tsx │ │ ├── Loadable.tsx │ │ └── index.tsx │ ├── CreateFormDialog │ │ ├── actions.ts │ │ ├── constants.ts │ │ ├── index.tsx │ │ ├── layout.tsx │ │ ├── reducer.ts │ │ ├── saga.ts │ │ └── selectors.ts │ ├── EditorPage │ │ ├── Helmet.tsx │ │ ├── Loadable.tsx │ │ ├── Responses │ │ │ ├── Layout.tsx │ │ │ ├── actions.ts │ │ │ ├── constants.ts │ │ │ ├── index.tsx │ │ │ ├── reducer.ts │ │ │ ├── saga.ts │ │ │ └── selectors.ts │ │ ├── SettingDialog.tsx │ │ ├── ShareDialog.tsx │ │ ├── UnlockDialog.tsx │ │ ├── actions.ts │ │ ├── constants.ts │ │ ├── index.tsx │ │ ├── layout.tsx │ │ ├── reducer.ts │ │ ├── saga.ts │ │ └── selectors.ts │ ├── FormPage │ │ ├── Helmet.tsx │ │ ├── Loadable.tsx │ │ ├── actions.ts │ │ ├── constants.ts │ │ ├── index.tsx │ │ ├── layout.tsx │ │ ├── reducer.ts │ │ ├── saga.ts │ │ ├── selectors.ts │ │ └── success.tsx │ ├── HomePage │ │ ├── Helmet.tsx │ │ ├── Loadable.tsx │ │ ├── index.tsx │ │ ├── layout.tsx │ │ ├── macbookpro13_front.png │ │ └── xs.png │ ├── NotFoundPage │ │ └── index.tsx │ └── PanelPage │ │ ├── Helmet.tsx │ │ ├── Layout.tsx │ │ ├── Loadable.tsx │ │ ├── actions.ts │ │ ├── constants.ts │ │ ├── index.tsx │ │ ├── reducer.ts │ │ ├── saga.ts │ │ └── selectors.ts ├── global.css ├── hydrate.tsx ├── index.d.ts ├── main.tsx ├── models │ ├── db.ts │ ├── formCache.ts │ └── keyCache.ts ├── polyfill.ts ├── reducers.ts ├── server.tsx ├── service │ ├── formCache │ │ └── saga.ts │ └── keyCache │ │ └── saga.ts ├── store.ts ├── styles.ts ├── tsconfig.json ├── utils │ ├── constants.ts │ ├── debounce.ts │ ├── injectReducer.tsx │ ├── injectSaga.tsx │ ├── mergeReducer.ts │ ├── reducerInjectors.ts │ ├── sagaInjectors.ts │ ├── types.ts │ └── wait.ts └── validate │ └── index.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | npm-debug.log* 3 | yarn-debug.log* 4 | yarn-error.log* 5 | 6 | dist/ 7 | 8 | db/ 9 | 10 | Dockerfile -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Use Node.js 12 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: 12 17 | - name: yarn install, build, and test 18 | run: | 19 | yarn 20 | yarn build 21 | env: 22 | CI: true 23 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Publish to Registry 14 | uses: elgohr/Publish-Docker-Github-Action@master 15 | with: 16 | name: eyhn/theform 17 | username: ${{ secrets.DOCKER_USERNAME }} 18 | password: ${{ secrets.DOCKER_PASSWORD }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | .DS_Store 64 | 65 | dist/ 66 | 67 | db/ -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "editor.tabSize": 2, 4 | "search.exclude": { 5 | "dist/**": true 6 | } 7 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:13.2.0-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json yarn.lock ./ 6 | RUN yarn install 7 | 8 | COPY . . 9 | 10 | RUN yarn build 11 | 12 | RUN ls ./dist/ 13 | 14 | EXPOSE 3000 15 | CMD [ "yarn", "server" ] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Form -------------------------------------------------------------------------------- /build/babel.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | export const babelOptions = { 4 | babelrc: false, 5 | presets: [ 6 | path.resolve(__dirname, '../node_modules/@babel/preset-env'), 7 | path.resolve(__dirname, '../node_modules/@babel/preset-react') 8 | ], 9 | plugins: [ 10 | path.resolve(__dirname, '../node_modules/@babel/plugin-transform-runtime') 11 | ] 12 | } 13 | 14 | export const babelSSROptions = { 15 | babelrc: false, 16 | presets: [ 17 | path.resolve(__dirname, '../node_modules/@babel/preset-env'), 18 | path.resolve(__dirname, '../node_modules/@babel/preset-react') 19 | ], 20 | plugins: [ 21 | path.resolve(__dirname, '../tools/loadable/babel'), 22 | path.resolve(__dirname, '../node_modules/@babel/plugin-transform-runtime') 23 | ] 24 | } -------------------------------------------------------------------------------- /build/rules.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { babelOptions, babelSSROptions } from './babel'; 3 | 4 | export const javascript = { 5 | test: /\.(js|jsx)?$/, 6 | exclude: [ 7 | path.resolve(__dirname, "../node_modules") 8 | ], 9 | use: [ 10 | { 11 | loader: 'babel-loader', 12 | options: babelOptions 13 | } 14 | ] 15 | } 16 | 17 | export const javascriptSSR = { 18 | test: /\.(js|jsx)?$/, 19 | exclude: [ 20 | path.resolve(__dirname, "../node_modules") 21 | ], 22 | use: [ 23 | { 24 | loader: 'babel-loader', 25 | options: babelSSROptions 26 | } 27 | ] 28 | } 29 | 30 | export const typescript = { 31 | test: /\.(ts|tsx)?$/, 32 | use: [ 33 | { 34 | loader: 'babel-loader', 35 | options: babelOptions 36 | }, 37 | { 38 | loader: 'ts-loader', 39 | options: { 40 | configFile: path.resolve(__dirname, '../view/tsconfig.json') 41 | } 42 | } 43 | ] 44 | } 45 | 46 | export const typescriptSSR = { 47 | test: /\.(ts|tsx)?$/, 48 | use: [ 49 | { 50 | loader: 'babel-loader', 51 | options: babelSSROptions 52 | }, 53 | { 54 | loader: 'ts-loader', 55 | options: { 56 | configFile: path.resolve(__dirname, '../view/tsconfig.json') 57 | } 58 | } 59 | ] 60 | } 61 | 62 | export const css = { 63 | test: /\.css$/, 64 | use: [ 65 | 'to-string-loader', 66 | { 67 | loader:'css-loader', 68 | options: { 69 | importLoaders: 1 70 | } 71 | }, 72 | { 73 | loader: 'postcss-loader', 74 | options: { 75 | ident: 'postcss', 76 | plugins: () => [ 77 | require('postcss-preset-env')(), 78 | require('cssnano')() 79 | ] 80 | } 81 | } 82 | ] 83 | } 84 | 85 | export const image = { 86 | test: /\.(png|jpg|jpeg|gif|bmp)$/, 87 | use: [{ 88 | loader: 'url-loader', 89 | options: { 90 | limit: 8192, 91 | name: '[path][name].[ext]', 92 | outputPath: 'images/', 93 | esModule: false 94 | } 95 | }] 96 | } 97 | 98 | export const performanceAssetFilter = (assetFilename: string) => { 99 | return assetFilename.endsWith('.js'); 100 | } -------------------------------------------------------------------------------- /build/webpack.config.csr.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import webpack from 'webpack'; 3 | import HtmlWebpackPlugin from 'html-webpack-plugin'; 4 | import { image, css, typescript, performanceAssetFilter } from './rules'; 5 | 6 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 7 | 8 | const HtmlWebpackConfig: HtmlWebpackPlugin.Options = { 9 | title: 'The Form', 10 | filename: 'index.html', 11 | hash: true, 12 | showErrors: true 13 | } 14 | 15 | const config: webpack.Configuration = { 16 | name: 'The Form', 17 | mode: process.env.NODE_ENV === 'production' ? 'production' : 'development', 18 | context: path.resolve(__dirname, '../view/'), 19 | target: 'web', 20 | entry: [ 21 | path.resolve(__dirname, '../view/main.tsx') 22 | ], 23 | output: { 24 | filename: 'bundle.js', 25 | publicPath: '/', 26 | chunkFilename: '[name].js', 27 | path: path.resolve(__dirname, '../dist/csr/') 28 | }, 29 | 30 | devtool: 'source-map', 31 | 32 | plugins: [ 33 | new HtmlWebpackPlugin(HtmlWebpackConfig), 34 | new BundleAnalyzerPlugin({ 35 | openAnalyzer: false, 36 | analyzerMode: 'static', 37 | reportFilename: './stats.html' 38 | }), 39 | new webpack.DefinePlugin({ 40 | API_SERVER: JSON.stringify('http://localhost:3000') 41 | }) 42 | ], 43 | 44 | resolve: { 45 | alias: { 46 | 'react-loadable': path.resolve(__dirname, '../tools/loadable'), 47 | 'config': path.resolve(__dirname, '../config.ts') 48 | }, 49 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 50 | modules: [path.resolve(__dirname, '../view'), 'node_modules'] 51 | }, 52 | module: { 53 | rules: [ 54 | typescript, 55 | css, 56 | image 57 | ] 58 | }, 59 | devServer: { 60 | port: parseInt(process.env.PORT) || 8888, 61 | host: 'localhost', 62 | publicPath: '/', 63 | contentBase: path.resolve(__dirname, '../view'), 64 | historyApiFallback: true, 65 | open: true, 66 | headers: { 67 | 'access-control-allow-origin': '*' 68 | } 69 | }, 70 | performance: { 71 | assetFilter: performanceAssetFilter, 72 | maxAssetSize: 300000, 73 | maxEntrypointSize: 300000 74 | }, 75 | node: { 76 | __dirname: true, 77 | __filename: true 78 | } 79 | }; 80 | 81 | export default config; -------------------------------------------------------------------------------- /development-note.md: -------------------------------------------------------------------------------- 1 | 测试表单:http://localhost:8888/editor/d257fb5f3d66dc4f36ac05b0ae2054445165d04a9ee3932397c7b475d372f134 2 | 测试表单密码:82558255 3 | 4 | Todo: 5 | 6 | - 缩短表单ID 7 | 8 | - 修改加密方式 9 | 10 | 使用 RSA 生成密钥后使用 AES 等对称加密算法保护私钥,使用时在客户端使用用户密码解密私钥。 11 | 12 | - 修改 @eyhn/crypto 13 | 14 | 兼容 Web crypto 和 NODE crypto ,完善测试内容 15 | 16 | - 修复 localStorage 储存 17 | 18 | - 服务端渲染需要重构 -------------------------------------------------------------------------------- /docs/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.5' 2 | 3 | services: 4 | db: 5 | image: postgres:alpine 6 | restart: unless-stopped 7 | volumes: 8 | - database:/var/lib/postgresql/data 9 | environment: 10 | POSTGRES_PASSWORD: theform 11 | POSTGRES_USER: theform 12 | 13 | theform: 14 | image: eyhn/theform 15 | restart: unless-stopped 16 | depends_on: 17 | - db 18 | environment: 19 | - DATABASE_URL=postgresql://theform:theform@db:5432/theform 20 | - VIRTUAL_HOST=theform.app 21 | 22 | proxy: 23 | image: jwilder/nginx-proxy:alpine 24 | restart: unless-stopped 25 | environment: 26 | - DEFAULT_HOST=theform.app 27 | volumes: 28 | - /var/run/docker.sock:/tmp/docker.sock:ro 29 | ports: 30 | - 80:80 31 | 32 | volumes: 33 | database: -------------------------------------------------------------------------------- /interface/Api/Form/index.ts: -------------------------------------------------------------------------------- 1 | import { IFormTemplate, IForm, IFormKey } from "@interface/Form"; 2 | 3 | export interface IApiUpdateFormRequest { 4 | template: IFormTemplate; 5 | } 6 | 7 | export type IApiUpdateFormResponse = IForm; 8 | 9 | export interface IApiNewFormRequest { 10 | date: string; 11 | template: IFormTemplate; 12 | key: IFormKey; 13 | } 14 | 15 | export type IApiNewFormResponse = IForm; 16 | 17 | export type IApiFetchFormResponse = IForm; -------------------------------------------------------------------------------- /interface/Api/Response/index.ts: -------------------------------------------------------------------------------- 1 | import { IResponse, IResponseKey } from "@interface/Response"; 2 | import { IFormTemplate } from "@interface/Form"; 3 | 4 | export interface IApiFetchFormResponse { 5 | responses: IResponse[]; 6 | } 7 | 8 | export interface IApiNewResponseRequest { 9 | date: string; 10 | template: IFormTemplate; 11 | encryptedData: string; 12 | key: IResponseKey; 13 | } 14 | 15 | export interface IApiNewResponseResponse { 16 | } -------------------------------------------------------------------------------- /interface/Form/ImageView/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface IImageViewTemplate { 2 | type: 'ImageView'; 3 | id: string; 4 | title?: string; 5 | url?: string; 6 | } 7 | -------------------------------------------------------------------------------- /interface/Form/MultiSelection/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface IMultiSelectionTextChoice { 2 | type: 'text', 3 | text: string 4 | } 5 | 6 | export interface IMultiSelectionOtherChoice { 7 | type: 'other' 8 | } 9 | 10 | export type IMultiSelectionChoice = IMultiSelectionTextChoice | IMultiSelectionOtherChoice; 11 | 12 | export interface IMultiSelectionValue { 13 | otherText?: string; 14 | choices?: IMultiSelectionChoice[]; 15 | } 16 | 17 | export interface IMultiSelectionTemplate { 18 | type: 'MultiSelection'; 19 | id: string; 20 | title?: string; 21 | description?: string; 22 | required?: boolean; 23 | choices: IMultiSelectionChoice[]; 24 | } 25 | -------------------------------------------------------------------------------- /interface/Form/ShortAnswer/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface IShortAnswerTemplate { 2 | type: 'ShortAnswer'; 3 | id: string; 4 | title?: string; 5 | description?: string; 6 | required?: boolean; 7 | } -------------------------------------------------------------------------------- /interface/Form/SingleSelection/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface ISingleSelectionTextChoice { 2 | type: 'text', 3 | text: string 4 | } 5 | 6 | export interface ISingleSelectionOtherChoice { 7 | type: 'other' 8 | } 9 | 10 | export type ISingleSelectionChoice = ISingleSelectionTextChoice | ISingleSelectionOtherChoice; 11 | 12 | export interface ISingleSelectionValue { 13 | otherText?: string; 14 | choice?: ISingleSelectionChoice; 15 | } 16 | 17 | export interface ISingleSelectionTemplate { 18 | type: 'SingleSelection'; 19 | id: string; 20 | title?: string; 21 | description?: string; 22 | required?: boolean; 23 | choices: ISingleSelectionChoice[] 24 | } 25 | -------------------------------------------------------------------------------- /interface/Form/TextField/index.d.ts: -------------------------------------------------------------------------------- 1 | export interface ITextFieldTemplate { 2 | type: 'TextField'; 3 | id: string; 4 | title?: string; 5 | description?: string; 6 | } 7 | -------------------------------------------------------------------------------- /interface/Form/index.d.ts: -------------------------------------------------------------------------------- 1 | import { IShortAnswerTemplate } from "./ShortAnswer"; 2 | import { IMultiSelectionTemplate, IMultiSelectionValue } from "./MultiSelection"; 3 | import { ISingleSelectionTemplate, ISingleSelectionValue } from "./SingleSelection"; 4 | import { ITextFieldTemplate } from "./TextField"; 5 | import { IImageViewTemplate } from "./ImageView"; 6 | 7 | export type IFormItemTemplate = (IShortAnswerTemplate | ISingleSelectionTemplate | IMultiSelectionTemplate | ITextFieldTemplate | IImageViewTemplate); 8 | 9 | export interface ITemplateMap { 10 | 'ShortAnswer': IShortAnswerTemplate; 11 | 'SingleSelection': ISingleSelectionTemplate; 12 | 'MultiSelection': IMultiSelectionTemplate; 13 | 'TextField': ITextFieldTemplate; 14 | 'ImageView': IImageViewTemplate; 15 | } 16 | 17 | export interface IFormTemplate { 18 | version?: string; 19 | title: string; 20 | description?: string; 21 | form: IFormItemTemplate[]; 22 | } 23 | 24 | /** 25 | * Parameters for form components to display special effects. 26 | */ 27 | export interface IFormItemMeta { 28 | 29 | /** 30 | * computed error message 31 | */ 32 | error?: string; 33 | } 34 | 35 | export interface IFormMeta { 36 | [id: string]: IFormItemMeta 37 | } 38 | 39 | export interface IFormValue { 40 | [id: string]: ({} | string | ISingleSelectionValue | IMultiSelectionValue) 41 | } 42 | 43 | export interface IFormKey { 44 | publicKey: string; 45 | encryptedPrivateKey: string; 46 | privateKeyMac: string; 47 | } 48 | 49 | export interface IForm { 50 | url: string; 51 | id: string; 52 | key: IFormKey; 53 | date: string; 54 | template: IFormTemplate; 55 | } 56 | -------------------------------------------------------------------------------- /interface/Response/index.d.ts: -------------------------------------------------------------------------------- 1 | import { IFormTemplate } from "@interface/Form"; 2 | 3 | export interface IResponseKey { 4 | encryptedKey: string; 5 | keyMac: string; 6 | } 7 | 8 | export interface IResponse { 9 | id: string; 10 | formId: string; 11 | date: string; 12 | template: IFormTemplate; 13 | encryptedData: string; 14 | key: IResponseKey; 15 | } 16 | -------------------------------------------------------------------------------- /interface/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from 'pino'; 2 | 3 | declare module 'koa' { 4 | interface ExtendableContext { 5 | log: Logger; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /schemas/Api/Form/NewForm.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$id": "http://theform.app/schemas/Api/Form/NewForm.json", 4 | "type": "object", 5 | "properties": { 6 | "template": { 7 | "$ref": "http://theform.app/schemas/FormTemplate.json" 8 | }, 9 | "date": { 10 | "type": "string" 11 | }, 12 | "key": { 13 | "type": "object", 14 | "properties": { 15 | "publicKey": { 16 | "type": "string" 17 | }, 18 | "encryptedPrivateKey": { 19 | "type": "string" 20 | }, 21 | "privateKeyMac": { 22 | "type": "string", 23 | "maxLength": 64, 24 | "minLength": 64 25 | } 26 | }, 27 | "required": [ 28 | "publicKey", 29 | "encryptedPrivateKey", 30 | "privateKeyMac" 31 | ] 32 | } 33 | }, 34 | "required": [ 35 | "template", 36 | "date", 37 | "key" 38 | ] 39 | } -------------------------------------------------------------------------------- /schemas/Api/Form/UpdateForm.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$id": "http://theform.app/schemas/Api/Form/UpdateForm.json", 4 | "type": "object", 5 | "properties": { 6 | "template": { 7 | "$ref": "http://theform.app/schemas/FormTemplate.json" 8 | } 9 | }, 10 | "required": [ 11 | "template" 12 | ] 13 | } -------------------------------------------------------------------------------- /schemas/Api/Response/NewResponse.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$id": "http://theform.app/schemas/Api/Response/NewResponse.json", 4 | "type": "object", 5 | "properties": { 6 | "date": { 7 | "type": "string" 8 | }, 9 | "template": { 10 | "$ref": "http://theform.app/schemas/FormTemplate.json" 11 | }, 12 | "encryptedData": { 13 | "type": "string" 14 | }, 15 | "key": { 16 | "type": "object", 17 | "properties": { 18 | "encryptedKey": { 19 | "type": "string" 20 | }, 21 | "keyMac": { 22 | "type": "string", 23 | "maxLength": 64, 24 | "minLength": 64 25 | } 26 | }, 27 | "required": [ 28 | "encryptedKey", 29 | "keyMac" 30 | ] 31 | } 32 | }, 33 | "required": [ 34 | "template", 35 | "date", 36 | "encryptedData", 37 | "key" 38 | ] 39 | } -------------------------------------------------------------------------------- /schemas/FormTemplate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$id": "http://theform.app/schemas/FormTemplate.json", 4 | "type": "object", 5 | "properties": { 6 | "version": { 7 | "type": "string" 8 | }, 9 | "title": { 10 | "type": "string" 11 | }, 12 | "description": { 13 | "type": "string" 14 | }, 15 | "form": { 16 | "type": "array", 17 | "items": { 18 | "anyOf": [ 19 | { 20 | "$ref": "ShortAnswer/ShortAnswerTemplate.json" 21 | }, 22 | { 23 | "$ref": "MultiSelection/MultiSelectionTemplate.json" 24 | }, 25 | { 26 | "$ref": "SingleSelection/SingleSelectionTemplate.json" 27 | }, 28 | { 29 | "$ref": "TextField/TextFieldTemplate.json" 30 | }, 31 | { 32 | "$ref": "ImageView/ImageViewTemplate.json" 33 | } 34 | ] 35 | } 36 | } 37 | }, 38 | "required": [ 39 | "title", 40 | "form" 41 | ] 42 | } -------------------------------------------------------------------------------- /schemas/ImageView/ImageViewTemplate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$id": "http://theform.app/schemas/ImageView/ImageViewTemplate.json", 4 | "type": "object", 5 | "properties": { 6 | "type": { 7 | "type": "string", 8 | "pattern": "ImageView" 9 | }, 10 | "id": { 11 | "type": "string" 12 | }, 13 | "title": { 14 | "type": "string" 15 | }, 16 | "url": { 17 | "type": "string" 18 | } 19 | }, 20 | "required": [ 21 | "type", 22 | "id" 23 | ] 24 | } -------------------------------------------------------------------------------- /schemas/MultiSelection/MultiSelectionChoice.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$id": "http://theform.app/schemas/MultiSelection/MultiSelectionChoice.json", 4 | "anyOf": [ 5 | { 6 | "type": "object", 7 | "properties": { 8 | "type": { 9 | "type": "string", 10 | "pattern": "text" 11 | }, 12 | "text": { 13 | "type": "string" 14 | } 15 | }, 16 | "required": ["type","text"] 17 | }, 18 | { 19 | "type": "object", 20 | "properties": { 21 | "type": { 22 | "type": "string", 23 | "pattern": "other" 24 | } 25 | }, 26 | "required": ["type"] 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /schemas/MultiSelection/MultiSelectionTemplate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$id": "http://theform.app/schemas/MultiSelection/MultiSelectionTemplate.json", 4 | "type": "object", 5 | "properties": { 6 | "type": { 7 | "type": "string", 8 | "pattern": "MultiSelection" 9 | }, 10 | "id": { 11 | "type": "string" 12 | }, 13 | "title": { 14 | "type": "string" 15 | }, 16 | "description": { 17 | "type": "string" 18 | }, 19 | "required": { 20 | "type": "boolean" 21 | }, 22 | "choices": { 23 | "type": "array", 24 | "minItems": 1, 25 | "items": { 26 | "$ref": "MultiSelectionChoice.json" 27 | } 28 | } 29 | }, 30 | "required": [ 31 | "type", 32 | "id", 33 | "choices" 34 | ] 35 | } -------------------------------------------------------------------------------- /schemas/ShortAnswer/ShortAnswerTemplate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$id": "http://theform.app/schemas/ShortAnswer/ShortAnswerTemplate.json", 4 | "type": "object", 5 | "properties": { 6 | "type": { 7 | "type": "string", 8 | "pattern": "ShortAnswer" 9 | }, 10 | "id": { 11 | "type": "string" 12 | }, 13 | "title": { 14 | "type": "string" 15 | }, 16 | "description": { 17 | "type": "string" 18 | }, 19 | "required": { 20 | "type": "boolean" 21 | } 22 | }, 23 | "required": [ 24 | "type", 25 | "id" 26 | ] 27 | } -------------------------------------------------------------------------------- /schemas/SingleSelection/SingleSelectionChoice.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$id": "http://theform.app/schemas/SingleSelection/SingleSelectionChoice.json", 4 | "anyOf": [ 5 | { 6 | "type": "object", 7 | "properties": { 8 | "type": { 9 | "type": "string", 10 | "pattern": "text" 11 | }, 12 | "text": { 13 | "type": "string" 14 | } 15 | }, 16 | "required": ["type","text"] 17 | }, 18 | { 19 | "type": "object", 20 | "properties": { 21 | "type": { 22 | "type": "string", 23 | "pattern": "other" 24 | } 25 | }, 26 | "required": ["type"] 27 | } 28 | ] 29 | } -------------------------------------------------------------------------------- /schemas/SingleSelection/SingleSelectionTemplate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$id": "http://theform.app/schemas/SingleSelection/SingleSelectionTemplate.json", 4 | "type": "object", 5 | "properties": { 6 | "type": { 7 | "type": "string", 8 | "pattern": "SingleSelection" 9 | }, 10 | "id": { 11 | "type": "string" 12 | }, 13 | "title": { 14 | "type": "string" 15 | }, 16 | "description": { 17 | "type": "string" 18 | }, 19 | "required": { 20 | "type": "boolean" 21 | }, 22 | "choices": { 23 | "type": "array", 24 | "minItems": 1, 25 | "items": { 26 | "$ref": "SingleSelectionChoice.json" 27 | } 28 | } 29 | }, 30 | "required": [ 31 | "type", 32 | "id", 33 | "choices" 34 | ] 35 | } -------------------------------------------------------------------------------- /schemas/TextField/TextFieldTemplate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json-schema.org/draft-06/schema#", 3 | "$id": "http://theform.app/schemas/TextField/TextFieldTemplate.json", 4 | "type": "object", 5 | "properties": { 6 | "type": { 7 | "type": "string", 8 | "pattern": "TextField" 9 | }, 10 | "id": { 11 | "type": "string" 12 | }, 13 | "title": { 14 | "type": "string" 15 | }, 16 | "description": { 17 | "type": "string" 18 | } 19 | }, 20 | "required": [ 21 | "type", 22 | "id" 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /schemas/test/FormTemplate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../FormTemplate.json", 3 | "title": "title", 4 | "description": "desc", 5 | "version": "0.0.0", 6 | "form": [ 7 | { 8 | "type": "SingleSelection", 9 | "id": "1", 10 | "title": "SingleSelection 1", 11 | "choices": [ 12 | { 13 | "type": "text", 14 | "text": "choice 1" 15 | }, 16 | { 17 | "type": "text", 18 | "text": "choice 2" 19 | }, 20 | { 21 | "type": "other" 22 | } 23 | ] 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | import Koa from 'koa'; 2 | import InjectionMiddlewares from './middlewares'; 3 | import logger from './logger'; 4 | 5 | const app = new Koa(); 6 | 7 | InjectionMiddlewares(app); 8 | 9 | app.listen(process.env.PORT || 3000, () => { 10 | logger.info('form.app API server start success.') 11 | }); 12 | -------------------------------------------------------------------------------- /server/logger.ts: -------------------------------------------------------------------------------- 1 | import pino from 'pino'; 2 | 3 | const logger = pino() 4 | 5 | export default logger; -------------------------------------------------------------------------------- /server/middlewares/api/index.ts: -------------------------------------------------------------------------------- 1 | import mount from 'koa-mount'; 2 | import compose from 'koa-compose'; 3 | import form from './form'; 4 | import response from './response'; 5 | 6 | export default mount('/api', compose([form, response])); 7 | -------------------------------------------------------------------------------- /server/middlewares/api/response.ts: -------------------------------------------------------------------------------- 1 | import Router from "koa-router"; 2 | import bodyParser from "koa-bodyparser"; 3 | import { formGetResponses, formPushResponse, formGet } from "../../models/form"; 4 | import { responseGet, responseCreate } from "../../models/response"; 5 | import { ApiResponseNewResponseSchema, errorsText } from '../../schemas'; 6 | import { IApiNewResponseRequest } from "@interface/Api/Response"; 7 | import crypto from '@eyhn/crypto'; 8 | import compose from "koa-compose"; 9 | 10 | const responseAPI = new Router(); 11 | 12 | responseAPI.use(bodyParser()); 13 | 14 | responseAPI.get('/form/:id/responses', async (ctx) => { 15 | 16 | const id = ctx.params.id; 17 | 18 | if (!await formGet(id)) { 19 | ctx.throw(404); 20 | return; 21 | } 22 | 23 | const responseIds = await formGetResponses(id); 24 | 25 | const responses = await Promise.all(responseIds.map(id => responseGet(id))); 26 | 27 | ctx.body = { 28 | responses: responses 29 | }; 30 | }); 31 | 32 | responseAPI.post('/form/:id/responses', async (ctx) => { 33 | 34 | const id = ctx.params.id; 35 | 36 | if (!await formGet(id)) { 37 | ctx.throw(404); 38 | return; 39 | } 40 | 41 | const body: IApiNewResponseRequest = ctx.request.body; 42 | 43 | if (!ApiResponseNewResponseSchema(body)) { 44 | ctx.throw(400, JSON.stringify({ 45 | message: errorsText(ApiResponseNewResponseSchema.errors), 46 | error: true 47 | })); 48 | return; 49 | } 50 | 51 | let responseId; 52 | 53 | do { 54 | responseId = crypto.tools.arrayBufferToHex(crypto.prng()(new Uint8Array(32))); 55 | } while (await responseGet(responseId)); 56 | 57 | await responseCreate(responseId, { 58 | id: responseId, 59 | formId: id, 60 | date: body.date, 61 | template: body.template, 62 | encryptedData: body.encryptedData, 63 | key: body.key 64 | }) 65 | 66 | await formPushResponse(id, responseId); 67 | 68 | ctx.body = {}; 69 | }); 70 | 71 | export default compose([ 72 | responseAPI.routes(), 73 | responseAPI.allowedMethods() 74 | ]); 75 | -------------------------------------------------------------------------------- /server/middlewares/cors.ts: -------------------------------------------------------------------------------- 1 | import koa from 'koa'; 2 | 3 | export default async function cors(ctx: koa.Context, next: () => Promise) { 4 | try { 5 | await next(); 6 | } finally { 7 | ctx.set('Access-Control-Allow-Origin', '*'); 8 | ctx.set('Access-Control-Allow-Headers', 'x-signature, Content-Type'); 9 | } 10 | } -------------------------------------------------------------------------------- /server/middlewares/frontend.ts: -------------------------------------------------------------------------------- 1 | import koa from 'koa'; 2 | import path from 'path'; 3 | import Router from 'koa-router'; 4 | import compose from 'koa-compose'; 5 | import koastatic from 'koa-static'; 6 | 7 | const render = require(path.resolve(process.cwd(), 'dist/ssr/server/server.js')).render; 8 | 9 | const frontend:koa.Middleware = async function (ctx) { 10 | ctx.body = await render(ctx.path, path.resolve(process.cwd(), 'dist/ssr/server/')); 11 | } 12 | 13 | var router = new Router(); 14 | 15 | router.get('*', frontend); 16 | 17 | export default compose([ 18 | koastatic(path.resolve(process.cwd(), 'dist/ssr/client')), 19 | router.routes(), 20 | router.allowedMethods() 21 | ]); 22 | -------------------------------------------------------------------------------- /server/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | import koa from 'koa'; 2 | import frontend from './frontend'; 3 | import api from './api'; 4 | import cors from './cors'; 5 | import logger from './logger'; 6 | 7 | const InjectionMiddlewares = (app: koa) => { 8 | app 9 | .use(logger) 10 | .use(cors) 11 | .use(api) 12 | .use(frontend) 13 | } 14 | 15 | export default InjectionMiddlewares; -------------------------------------------------------------------------------- /server/middlewares/logger.ts: -------------------------------------------------------------------------------- 1 | import koa from 'koa'; 2 | import pinoHttp from 'pino-http'; 3 | 4 | const wrap = pinoHttp(); 5 | export default async function logger(ctx: koa.Context, next: () => Promise) { 6 | try { 7 | wrap(ctx.req, ctx.res); 8 | ctx.log = ctx.req.log; 9 | await next(); 10 | } catch (e) { 11 | ctx.res.err = e; 12 | throw e 13 | } 14 | } -------------------------------------------------------------------------------- /server/models/database.ts: -------------------------------------------------------------------------------- 1 | import Keyv from 'keyv'; 2 | 3 | export class Database { 4 | url: string; 5 | options: Keyv.Options; 6 | database: Keyv; 7 | constructor(url?: string, options?: Keyv.Options) { 8 | this.url = url; 9 | this.options = options; 10 | this.database = new Keyv(url, options); 11 | } 12 | get = (key: string) => this.database.get(key); 13 | set = (key: string, value: string, ttl?: number) => this.database.set(key, value, ttl); 14 | delete = (key: string) => this.database.delete(key); 15 | namespace = (namespace: string) => new Database(this.url, {...this.options, namespace}); 16 | } 17 | 18 | const db = new Database(process.env.DATABASE_URL); 19 | 20 | export default db; -------------------------------------------------------------------------------- /server/models/form.ts: -------------------------------------------------------------------------------- 1 | import database from "./database"; 2 | import { IFormTemplate, IForm } from "@interface/Form"; 3 | 4 | const db = database.namespace('form'); 5 | 6 | function parseResponses(responses: string): string[] { 7 | return responses ? responses.split(',') : []; 8 | } 9 | 10 | function parseForm(form: string): IForm { 11 | return form ? JSON.parse(form) : null; 12 | } 13 | 14 | function stringifyResponses(responses: string[]): string { 15 | return responses.join(','); 16 | } 17 | 18 | export const formGet = async (id: string) => parseForm(await db.get(`form:${id}`)); 19 | export const formSet = async (id: string, form: IForm) => await db.set(`form:${id}`, JSON.stringify(form)); 20 | export const formGetResponses = async (id: string) => parseResponses(await db.get(`form:responses:${id}`)); 21 | export const formSetResponses = async (id: string, responses: string[]) => await db.set(`form:responses:${id}`, stringifyResponses(responses)); 22 | 23 | export const formCreate = async (id: string, form: IForm) => { 24 | await formSet(id, form); 25 | await formSetResponses(id, []); 26 | } 27 | 28 | export const formUpdateTemplate = async (id: string, template: IFormTemplate) => { 29 | const form = await formGet(id); 30 | await formSet(id, {...form,template}); 31 | } 32 | 33 | export const formPushResponse = async (id: string, responseId: string) => { 34 | const responses = await formGetResponses(id); 35 | responses.push(responseId); 36 | await formSetResponses(id, responses); 37 | } 38 | -------------------------------------------------------------------------------- /server/models/response.ts: -------------------------------------------------------------------------------- 1 | import database from "./database"; 2 | import { IResponse } from "@interface/Response"; 3 | 4 | const db = database.namespace('response'); 5 | 6 | export function parseResponse(response: string): IResponse { 7 | if (response) { 8 | return JSON.parse(response); 9 | } 10 | return null; 11 | } 12 | 13 | export const responseGet = async (id: string) => parseResponse(await db.get(`response:${id}`)); 14 | 15 | export const responseCreate = async (id: string, response: IResponse) => { 16 | await db.set(`response:${id}`, JSON.stringify(response)); 17 | } 18 | 19 | export const responseUpdate = async (id: string, response: IResponse) => { 20 | await db.set(`response:${id}`, JSON.stringify(response)); 21 | } 22 | -------------------------------------------------------------------------------- /server/schemas.ts: -------------------------------------------------------------------------------- 1 | import globby from 'globby'; 2 | import path from 'path'; 3 | import fs from 'fs-extra'; 4 | import Ajv from 'ajv'; 5 | 6 | const schemafiles = globby.sync(['**/*.json', '!test'], { 7 | cwd: path.resolve(process.cwd(), 'schemas'), 8 | absolute: true 9 | }); 10 | 11 | const schemas = schemafiles.map(schemafile => { 12 | return JSON.parse(fs.readFileSync(schemafile, 'utf8')); 13 | }); 14 | 15 | const ajv = new Ajv({ 16 | verbose: true 17 | }); 18 | ajv.addMetaSchema(require('ajv/lib/refs/json-schema-draft-06.json')); 19 | ajv.addSchema([schemas]) 20 | 21 | export const FormTemplateSchema = ajv.getSchema('http://theform.app/schemas/FormTemplate.json'); 22 | 23 | export const ApiFormNewFormSchema = ajv.getSchema('http://theform.app/schemas/Api/Form/NewForm.json'); 24 | 25 | export const ApiFormUpdateFormSchema = ajv.getSchema('http://theform.app/schemas/Api/Form/UpdateForm.json'); 26 | 27 | export const ApiResponseNewResponseSchema = ajv.getSchema('http://theform.app/schemas/Api/Response/NewResponse.json'); 28 | 29 | export const errorsText = (errors: Ajv.ErrorObject[]) => { 30 | return ajv.errorsText(errors, { 31 | separator: '\n' 32 | }) 33 | }; 34 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "sourceMap": true, 5 | "noImplicitAny": true, 6 | "module": "commonjs", 7 | "target": "esnext", 8 | "jsx": "preserve", 9 | "experimentalDecorators": true, 10 | "allowSyntheticDefaultImports": true, 11 | "esModuleInterop": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "moduleResolution": "node", 15 | "typeRoots": [ 16 | "./node_modules/@types" 17 | ], 18 | "paths": { 19 | "@interface/*": ["../interface/*"] 20 | }, 21 | "outDir": "../dist" 22 | }, 23 | "include": [ 24 | "**/*" 25 | ] 26 | } -------------------------------------------------------------------------------- /test/makeTestData.ts: -------------------------------------------------------------------------------- 1 | import fetch from 'isomorphic-fetch'; 2 | 3 | const SERVER_HOST = 'http://127.0.0.1:3000'; 4 | 5 | // create test form 6 | (async function () { 7 | const template = { 8 | version: '0.0.0', 9 | title: '派对邀请', 10 | description: '关于周末派对,做一个小调查', 11 | form: [ 12 | { 13 | type: 'ShortAnswer', 14 | id: '2', 15 | title: '总共有多少人一起参加?', 16 | required: true 17 | }, 18 | { 19 | type: 'SingleSelection', 20 | id: '3', 21 | title: '总共有多少人一起参加?', 22 | choices: [{ 23 | type: 'text', 24 | text: '123' 25 | }, { 26 | type: 'text', 27 | text: '123456' 28 | }, { 29 | type: 'text', 30 | text: '321' 31 | }, { 32 | type: 'other' 33 | }] 34 | }, 35 | { 36 | type: 'TextField', 37 | id: 'text', 38 | title: '标题', 39 | description: '表单说明' 40 | }, 41 | { 42 | type: 'ImageView', 43 | id: 'image', 44 | title: '图片标题' 45 | }, 46 | { 47 | type: 'MultiSelection', 48 | id: '4', 49 | title: '总共有多少人一起参加?', 50 | choices: [{ 51 | type: 'text', 52 | text: '123' 53 | }, { 54 | type: 'text', 55 | text: '123456' 56 | }, { 57 | type: 'text', 58 | text: '321' 59 | }, { 60 | type: 'other' 61 | }] 62 | }] 63 | }; 64 | 65 | const date = new Date().toUTCString(); 66 | 67 | const password = 'apple' + date; 68 | 69 | const RSAKey = require('../rsa/rsa2.js'); 70 | 71 | const generatersa = new RSAKey(); 72 | generatersa.generate(1024, '10001', password); 73 | const publickey = generatersa.n.toString(16); 74 | 75 | const jsondata = { 76 | publicKey: publickey, 77 | template: template, 78 | date: date 79 | }; 80 | 81 | const data = JSON.stringify(jsondata) 82 | 83 | var hSig = generatersa.sign(data, 'sha256'); 84 | 85 | const res = await fetch(SERVER_HOST + '/api/form', { 86 | method: 'POST', 87 | body: data, 88 | headers: { 89 | 'content-type': 'application/json', 90 | 'x-signature': hSig 91 | } 92 | }); 93 | console.log(await res.text()) 94 | })() 95 | -------------------------------------------------------------------------------- /test/process.ts: -------------------------------------------------------------------------------- 1 | import crypto from '@eyhn/crypto'; 2 | 3 | // 模拟后端数据库 4 | const serverdb = {} as any; 5 | 6 | // -------------------------------------------------- 7 | 8 | { 9 | // 表单创建 10 | // 生成 rsa 公钥和私钥 11 | const {n: publickey, d: privatekey} = crypto.rsa.generate(1024, 10001); 12 | 13 | // 设置密码 14 | const password = 'hello1'; 15 | 16 | const aeskey = crypto.pbkdf2.sha256( 17 | crypto.tools.textToArrayBuffer(password), 18 | crypto.tools.textToArrayBuffer('miku'), 19 | 5000, 20 | 32 21 | ); 22 | 23 | // 使用密码加密 rsa 私钥 24 | const encryptedPrivatekey = crypto.aes.ctr.encrypt( 25 | aeskey, 26 | privatekey 27 | ); 28 | 29 | const privateKeyMac = crypto.hmac.sha256(aeskey, privatekey); 30 | 31 | // 上传公钥和加密后的私钥 32 | serverdb.publickey = publickey; 33 | serverdb.encryptedPrivatekey = encryptedPrivatekey; 34 | serverdb.privateKeyMac = privateKeyMac; 35 | } 36 | 37 | // -------------------------------------------------- 38 | 39 | { 40 | // 提交表单 41 | // 生成 aes 密钥 42 | const aeskey = crypto.prng()(new Uint8Array(32)); 43 | 44 | // 加密表单内容 45 | const formdata = 'abc'; 46 | const encryptedFormdata = crypto.aes.ctr.encrypt( 47 | aeskey, 48 | crypto.tools.textToArrayBuffer(formdata) 49 | ); 50 | 51 | // 加密 aes 密钥 52 | const encryptedAESkey = crypto.rsa.encrypt(aeskey, serverdb.publickey, 10001); 53 | 54 | // 上传加密后的表单内容和加密后的 aes 密钥 55 | serverdb.encryptedFormdata = encryptedFormdata; 56 | serverdb.encryptedAESkey = encryptedAESkey; 57 | } 58 | 59 | // -------------------------------------------------- 60 | 61 | { 62 | // 解密表单数据 63 | // 输入密码 64 | const password = 'hello'; 65 | 66 | // 解密 rsa 密钥 67 | const privatekey = crypto.aes.ctr.decrypt( 68 | crypto.pbkdf2.sha256( 69 | crypto.tools.textToArrayBuffer(password), 70 | crypto.tools.textToArrayBuffer('miku'), 71 | 5000, 72 | 32 73 | ), 74 | serverdb.encryptedPrivatekey 75 | ); 76 | 77 | // 解密 aes 密钥 78 | const aeskey = crypto.rsa.decrypt(privatekey, serverdb.publickey, serverdb.encryptedAESkey); 79 | 80 | // 解密表单内容 81 | const formdata = crypto.tools.arrayBufferToText( 82 | crypto.aes.ctr.decrypt(aeskey, serverdb.encryptedFormdata) 83 | ); 84 | 85 | console.log(formdata === 'abc' ? 'success' : 'error') 86 | } 87 | -------------------------------------------------------------------------------- /tools/loadable/babel.js: -------------------------------------------------------------------------------- 1 | module.exports = function({ types: t }) { 2 | return { 3 | visitor: { 4 | ImportDeclaration(path) { 5 | let source = path.node.source.value; 6 | if (source !== 'react-loadable') return; 7 | 8 | let defaultSpecifier = path.get('specifiers').find(specifier => { 9 | return specifier.isImportDefaultSpecifier(); 10 | }); 11 | 12 | if (!defaultSpecifier) return; 13 | 14 | let bindingName = defaultSpecifier.node.local.name; 15 | let binding = path.scope.getBinding(bindingName); 16 | 17 | binding.referencePaths.forEach(refPath => { 18 | let callExpression = refPath.parentPath; 19 | 20 | if ( 21 | callExpression.isMemberExpression() && 22 | callExpression.node.computed === false && 23 | callExpression.get('property').isIdentifier({ name: 'Map' }) 24 | ) { 25 | callExpression = callExpression.parentPath; 26 | } 27 | 28 | if (!callExpression.isCallExpression()) return; 29 | 30 | let args = callExpression.get('arguments'); 31 | if (args.length !== 1) throw callExpression.error; 32 | 33 | let options = args[0]; 34 | if (!options.isObjectExpression()) return; 35 | 36 | let properties = options.get('properties'); 37 | let propertiesMap = {}; 38 | 39 | properties.forEach(property => { 40 | let key = property.get('key'); 41 | propertiesMap[key.node.name] = property; 42 | }); 43 | 44 | if (propertiesMap.webpack) { 45 | return; 46 | } 47 | 48 | let loaderMethod = propertiesMap.loader.get('value'); 49 | let dynamicImports = []; 50 | 51 | loaderMethod.traverse({ 52 | Import(path) { 53 | dynamicImports.push(path.parentPath); 54 | } 55 | }); 56 | 57 | if (!dynamicImports.length) return; 58 | 59 | propertiesMap.loader.insertAfter( 60 | t.objectProperty( 61 | t.identifier('webpack'), 62 | t.arrowFunctionExpression( 63 | [], 64 | t.arrayExpression( 65 | dynamicImports.map(dynamicImport => { 66 | return t.callExpression( 67 | t.memberExpression( 68 | t.identifier('require'), 69 | t.identifier('resolveWeak'), 70 | ), 71 | [dynamicImport.get('arguments')[0].node], 72 | ) 73 | }) 74 | ) 75 | ) 76 | ) 77 | ); 78 | }); 79 | } 80 | } 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /tools/loadable/webpack.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const url = require('url'); 3 | const RawSource = require('webpack-sources/lib/RawSource'); 4 | 5 | function buildManifest(compiler, compilation) { 6 | let context = compiler.options.context; 7 | let manifest = {__moduleids: []}; 8 | 9 | compilation.chunks.forEach(chunk => { 10 | chunk.files.forEach(file => { 11 | chunk.forEachModule(module => { 12 | let id = module.id; 13 | let name = typeof module.libIdent === 'function' ? module.libIdent({ context }) : null; 14 | let publicPath = url.resolve(compilation.outputOptions.publicPath || '', file); 15 | 16 | if (!manifest[id]) { 17 | manifest[id] = []; 18 | } 19 | 20 | manifest[id].push({ name, file, publicPath }); 21 | }); 22 | }); 23 | }); 24 | 25 | return manifest; 26 | } 27 | 28 | class ReactLoadablePlugin { 29 | constructor(opts = {}) { 30 | this.filename = opts.filename; 31 | } 32 | 33 | apply(compiler) { 34 | compiler.plugin('emit', (compilation, callback) => { 35 | const manifest = buildManifest(compiler, compilation); 36 | var json = JSON.stringify(manifest, null, 2); 37 | compilation.assets[this.filename] = new RawSource(json); 38 | callback(); 39 | }); 40 | } 41 | } 42 | 43 | function getBundles(manifest, moduleIds) { 44 | return moduleIds.reduce((bundles, moduleId) => { 45 | return bundles.concat(manifest[moduleId]); 46 | }, []); 47 | } 48 | 49 | exports.ReactLoadablePlugin = ReactLoadablePlugin; 50 | exports.getBundles = getBundles; 51 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "sourceMap": true, 5 | "noImplicitAny": true, 6 | "module": "commonjs", 7 | "target": "esnext", 8 | "jsx": "preserve", 9 | "experimentalDecorators": true, 10 | "allowSyntheticDefaultImports": true, 11 | "esModuleInterop": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "moduleResolution": "node", 15 | "typeRoots": [ 16 | "./node_modules/@types" 17 | ], 18 | "paths": { 19 | "@interface/*": ["./interface/*"] 20 | }, 21 | "outDir": "dist" 22 | }, 23 | "include": [ 24 | "**/*" 25 | ], 26 | "exclude": [ 27 | "node_modules", 28 | "./view" 29 | ] 30 | } -------------------------------------------------------------------------------- /view/api/ApiError.ts: -------------------------------------------------------------------------------- 1 | export default class ApiError extends Error { 2 | status: number; 3 | constructor( 4 | public response: Response) { 5 | super(response.status + ' ' + response.statusText); 6 | this.name = 'ApiError'; 7 | this.status = response.status; 8 | } 9 | } -------------------------------------------------------------------------------- /view/api/Form.ts: -------------------------------------------------------------------------------- 1 | import ApiError from "./ApiError"; 2 | import fetch from 'isomorphic-fetch'; 3 | import NetworkError from "./NetworkError"; 4 | import { IFormTemplate, IFormKey } from "@interface/Form"; 5 | import { IApiNewFormRequest, IApiUpdateFormRequest, IApiNewFormResponse, IApiFetchFormResponse, IApiUpdateFormResponse } from "@interface/Api/Form"; 6 | 7 | export async function apiFetchForm(id: string): Promise { 8 | let res; 9 | try { 10 | res = await fetch(API_SERVER + '/api/form/' + id); 11 | } catch (err) { 12 | throw new NetworkError(err); 13 | } 14 | if (res.ok) { 15 | const json = await res.json(); 16 | return json; 17 | } else { 18 | throw new ApiError(res); 19 | } 20 | } 21 | 22 | export async function apiNewForm(template: IFormTemplate, key: IFormKey, date: string, signFunc: (data: string) => string): Promise { 23 | const body: IApiNewFormRequest = { 24 | date: date, 25 | template: template, 26 | key: key 27 | }; 28 | const rawbody = JSON.stringify(body); 29 | let res = null; 30 | try { 31 | res = await fetch(API_SERVER + '/api/form/', { 32 | method: 'POST', 33 | headers: { 34 | 'content-type': 'application/json', 35 | 'x-signature': signFunc(rawbody) 36 | }, 37 | body: rawbody 38 | }); 39 | } catch (err) { 40 | throw new NetworkError(err); 41 | } 42 | if (res.ok) { 43 | const json = await res.json(); 44 | return json; 45 | } else { 46 | throw new ApiError(res); 47 | } 48 | } 49 | 50 | export async function apiUpdateForm(id: string, template: IFormTemplate, signFunc: (data: string) => string): Promise { 51 | const body: IApiUpdateFormRequest = { 52 | template: template 53 | }; 54 | const rawbody = JSON.stringify(body); 55 | let res; 56 | try { 57 | res = await fetch(API_SERVER + '/api/form/' + id, { 58 | method: 'POST', 59 | headers: { 60 | 'content-type': 'application/json', 61 | "x-signature": signFunc(rawbody) 62 | }, 63 | body: rawbody 64 | }); 65 | } catch (err) { 66 | throw new NetworkError(err); 67 | } 68 | if (res.ok) { 69 | const json = await res.json(); 70 | return json; 71 | } else { 72 | throw new ApiError(res); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /view/api/NetworkError.ts: -------------------------------------------------------------------------------- 1 | export default class NetworkError extends Error { 2 | source: Error; 3 | constructor(err: Error) { 4 | super(err.message); 5 | this.name = 'NetworkError'; 6 | this.source = err; 7 | } 8 | } -------------------------------------------------------------------------------- /view/api/Response.ts: -------------------------------------------------------------------------------- 1 | import { IApiNewResponseResponse, IApiNewResponseRequest, IApiFetchFormResponse } from "@interface/Api/Response"; 2 | import { IResponseKey } from "@interface/Response"; 3 | import { IFormTemplate } from "@interface/Form"; 4 | import NetworkError from "./NetworkError"; 5 | import ApiError from "./ApiError"; 6 | 7 | export async function apiNewFormResponse(formId: string,encryptedData: string, template: IFormTemplate, key: IResponseKey, date: string): Promise { 8 | const body: IApiNewResponseRequest = { 9 | encryptedData: encryptedData, 10 | date: date, 11 | template: template, 12 | key: key 13 | }; 14 | let res = null; 15 | try { 16 | res = await fetch(API_SERVER + '/api/form/' + formId + '/responses', { 17 | method: 'POST', 18 | headers: { 19 | 'content-type': 'application/json' 20 | }, 21 | body: JSON.stringify(body) 22 | }); 23 | } catch (err) { 24 | throw new NetworkError(err); 25 | } 26 | if (res.ok) { 27 | const json = await res.json(); 28 | return json; 29 | } else { 30 | throw new ApiError(res); 31 | } 32 | } 33 | 34 | export async function apiFetchFormResponse(formId: string): Promise { 35 | let res; 36 | try { 37 | res = await fetch(API_SERVER + '/api/form/' + formId + '/responses'); 38 | } catch (err) { 39 | throw new NetworkError(err); 40 | } 41 | if (res.ok) { 42 | const json = await res.json(); 43 | return json; 44 | } else { 45 | throw new ApiError(res); 46 | } 47 | } -------------------------------------------------------------------------------- /view/blog/create-form.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/EYHN/Form/d20768625f33dfaf44727de01403382c2aeb8c8d/view/blog/create-form.png -------------------------------------------------------------------------------- /view/blog/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Content from 'containers/BlogPage/Content'; 3 | 4 | const blog = { 5 | 'why-theform': { 6 | title: '为什么要做 The Form ?', 7 | date: '2019年12月7日', 8 | auchor: 'by EYHN', 9 | content: <> 10 | 目前市面上有很多问卷系统,比如国内有 腾讯问卷,国外有 Google Form,为什么我还要搭建 The Form? 11 | 我们不收集你的数据 12 | 13 | 很多免费问卷系统,都在收集你的数据。 14 | 15 | 16 | 在大多数问卷系统中,你提交的问卷都是明文发送给后端,服务提供商可以随意读取你提交的数据。你所作的问卷调查都是在替腾讯,Google 这些公司收集数据。 17 | 18 | 19 | 在 The Form 中填写的问卷会在浏览器中经过加密再发送到服务器。只有问卷创建时输入的密码才能解密数据。 20 | 服务器无法读取问卷的内容,你的数据始终是你的隐私。 21 | 22 | 23 | 24 | } 25 | } 26 | 27 | export default blog; -------------------------------------------------------------------------------- /view/components/Activity.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import injectSheet, { Styles, WithStyles } from 'react-jss'; 4 | import AppBar from './AppBar'; 5 | import AppBarIconButton from './AppBar/AppBarIconButton'; 6 | import AppBarTitle from './AppBar/AppBarTitle'; 7 | import Clear from './icons/Clear'; 8 | import AppBarButton from './AppBar/AppBarButton'; 9 | import classNames from 'classnames'; 10 | 11 | const styles: Styles = { 12 | root: { 13 | position: 'fixed', 14 | width: '100%', 15 | height: '100%', 16 | top: 0, 17 | left: 0, 18 | zIndex: 1001, 19 | background: '#fff' 20 | }, 21 | container: { 22 | width: '100%', 23 | height: '100%', 24 | display: 'flex', 25 | flexDirection: 'column', 26 | justifyContent : 'center', 27 | alignItems: 'start', 28 | '&>*': { 29 | width: '100%' 30 | } 31 | }, 32 | appbar: { 33 | margin: '0 0 24px' 34 | }, 35 | body: { 36 | flexGrow: 1 37 | } 38 | } 39 | 40 | interface IProps { 41 | form?: boolean; 42 | title?: string; 43 | submitLabel?: React.ReactNode; 44 | onClose?: () => void; 45 | onSubmit?: () => void; 46 | bodyClassName?: string; 47 | } 48 | 49 | class Activity extends React.PureComponent> { 50 | activityNode = document.createElement('div'); 51 | prevOverflow = 'auto'; 52 | componentDidMount() { 53 | this.prevOverflow = document.body.style.overflow 54 | document.body.appendChild(this.activityNode); 55 | document.body.style.overflow = 'hidden'; 56 | } 57 | componentWillUnmount() { 58 | document.body.removeChild(this.activityNode); 59 | document.body.style.overflow = this.prevOverflow; 60 | } 61 | 62 | handleFormSubmit: React.FormEventHandler = (e) => { 63 | const { onSubmit } = this.props; 64 | 65 | if (typeof onSubmit === 'function') { 66 | onSubmit(); 67 | } 68 | 69 | e.preventDefault(); 70 | } 71 | 72 | public render() { 73 | const { onClose, onSubmit, form = false, classes, children, title, bodyClassName, submitLabel='提交' } = this.props; 74 | 75 | const Container = form ? 'form' : 'div'; 76 | 77 | return ReactDOM.createPortal( 78 |
79 | 80 | 84 | 85 | {title} 86 | 87 | } 88 | right={{submitLabel}} 89 | /> 90 |
91 | {children} 92 |
93 |
94 |
, this.activityNode); 95 | } 96 | } 97 | 98 | export default injectSheet(styles)(Activity); 99 | -------------------------------------------------------------------------------- /view/components/AppBar/AppBarButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import injectSheet, { WithStyles } from 'react-jss'; 3 | import classNames from 'classnames'; 4 | import Button, { IButtonProps } from 'components/Button'; 5 | import { Styles } from 'jss'; 6 | 7 | interface IAppBarButtonProps { 8 | className?: string; 9 | onClick?: React.MouseEventHandler; 10 | } 11 | 12 | const styles: Styles = { 13 | button: { 14 | height: '48px', 15 | minWidth: '64px', 16 | fontWeight: 500, 17 | fontSize: '0.9em' 18 | } 19 | } 20 | 21 | const AppBarButton: React.SFC & IButtonProps> = ({className, children, classes, ...props}) => ( 22 | 23 | ); 24 | 25 | export default injectSheet(styles)(AppBarButton); -------------------------------------------------------------------------------- /view/components/AppBar/AppBarIcon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ISvgIconProps } from '../icons/SvgIcon'; 3 | import injectSheet, { WithStyles } from 'react-jss'; 4 | import classNames from 'classnames'; 5 | import { Styles } from 'jss'; 6 | 7 | interface IAppBarIconProps { 8 | className?: string; 9 | icon: (props: ISvgIconProps) => JSX.Element; 10 | onClick?: React.MouseEventHandler; 11 | } 12 | 13 | const styles: Styles = { 14 | icon: { 15 | height: '48px', 16 | padding: '12px' 17 | } 18 | } 19 | 20 | const AppBarIcon: React.SFC> = ({icon: Icon, onClick, className, classes}) => ( 21 | 22 | ); 23 | 24 | export default injectSheet(styles)(AppBarIcon); -------------------------------------------------------------------------------- /view/components/AppBar/AppBarIconButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ISvgIconProps } from '../icons/SvgIcon'; 3 | import injectSheet, { WithStyles } from 'react-jss'; 4 | import classNames from 'classnames'; 5 | import AppBarIcon from './AppBarIcon'; 6 | import { Styles } from 'jss'; 7 | 8 | interface IAppBarIconProps { 9 | className?: string; 10 | icon: (props: ISvgIconProps) => JSX.Element; 11 | onClick?: React.MouseEventHandler; 12 | } 13 | 14 | const styles: Styles = { 15 | button: { 16 | cursor: 'pointer' 17 | } 18 | } 19 | 20 | const AppBarIconButton: React.SFC> = ({icon, onClick, className, classes}) => ( 21 | 22 | ); 23 | 24 | export default injectSheet(styles)(AppBarIconButton); -------------------------------------------------------------------------------- /view/components/AppBar/AppBarTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import injectSheet, { Styles, WithStyles } from 'react-jss'; 3 | import classNames from 'classnames'; 4 | 5 | interface IAppBarTitleProps { 6 | className?: string; 7 | } 8 | 9 | const styles: Styles = { 10 | title: { 11 | fontWeight: 600, 12 | fontSize: '1.25rem', 13 | lineHeight: '48px', 14 | padding: '0 12px', 15 | display: 'inline-block' 16 | // whiteSpace: 'nowrap', 17 | // textOverflow: 'ellipsis', 18 | // overflow: 'hidden', 19 | // maxWidth: '50%' 20 | } 21 | } 22 | 23 | const AppBarTitle: React.SFC> = ({children, className, classes}) => ( 24 |

{children}

25 | ); 26 | 27 | export default injectSheet(styles)(AppBarTitle); -------------------------------------------------------------------------------- /view/components/AppBar/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import injectSheet, { WithStyles } from 'react-jss'; 3 | import classNames from 'classnames'; 4 | import { Styles } from 'jss'; 5 | 6 | export interface IAppBarProps { 7 | className?: string; 8 | left?: React.ReactNode; 9 | right?: React.ReactNode; 10 | } 11 | 12 | const styles: Styles = { 13 | main: { 14 | display: 'flex', 15 | position: 'relative', 16 | justifyContent: 'space-between', 17 | padding: '10px 8px', 18 | flexWrap: 'wrap', 19 | color: '#fff', 20 | backgroundColor: '#1565c0' 21 | }, 22 | side: { 23 | display: 'flex', 24 | flexGrow: 1, 25 | alignItems: 'center' 26 | }, 27 | rightSide: { 28 | justifyContent: 'flex-end' 29 | } 30 | } 31 | 32 | const AppBar: React.SFC> = ({ 33 | left, right, classes, className 34 | }) => { 35 | return ( 36 |
37 |
38 | {left} 39 |
40 |
41 | {right} 42 |
43 |
44 | ); 45 | }; 46 | 47 | export default injectSheet(styles)(AppBar); -------------------------------------------------------------------------------- /view/components/Asterisk.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import injectSheet, { WithStyles } from "react-jss"; 3 | import classNames from 'classnames'; 4 | 5 | const styles = { 6 | asterisk: { 7 | color: '#db4437' 8 | } 9 | } 10 | 11 | const Asterisk: React.SFC<{className?: string} & WithStyles> = ({classes, className, children}) => ( 12 | 13 | * 14 | {children && {children}} 15 | 16 | ) 17 | 18 | export default injectSheet(styles)(Asterisk) 19 | -------------------------------------------------------------------------------- /view/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import injectSheet, { WithStyles } from "react-jss"; 3 | import classNames from 'classnames'; 4 | import { Styles } from 'jss'; 5 | 6 | const styles: Styles = { 7 | button: { 8 | display: 'inline-block', 9 | fontSize: '1em', 10 | height: '36px', 11 | lineHeight: '36px', 12 | minWidth: '64px', 13 | margin: '0px', 14 | padding: '0px 10px', 15 | border: '10px', 16 | cursor: 'pointer', 17 | textDecoration: 'none', 18 | fontWeight: 600, 19 | outline: 'none', 20 | position: 'relative', 21 | borderRadius: '4px', 22 | userSelect: 'none', 23 | overflow: 'hidden', 24 | backgroundColor: 'transparent', 25 | textAlign: 'center', 26 | color: 'inherit', 27 | '-webkit-tap-highlight-color': 'transparent', 28 | transition: 'background-color 450ms cubic-bezier(0.23, 1, 0.32, 1) 0ms', 29 | '&:active': { 30 | backgroundColor: 'rgba(0, 0, 0, 0.2)', 31 | } 32 | }, 33 | shadow: { 34 | boxShadow: 'rgba(0, 0, 0, 0.117647) 0px 1px 6px, rgba(0, 0, 0, 0.117647) 0px 1px 4px', 35 | '&:hover': { 36 | boxShadow: 'rgba(0, 0, 0, 0.156863) 0px 3px 10px, rgba(0, 0, 0, 0.227451) 0px 3px 10px' 37 | } 38 | }, 39 | primary: { 40 | color: '#003c8f', 41 | '&:active': { 42 | backgroundColor: 'rgba(21, 101, 192, 0.2)', 43 | } 44 | }, 45 | disabled: { 46 | opacity: 0.7 47 | } 48 | } 49 | 50 | export interface IButtonProps { 51 | onClick?: React.MouseEventHandler; 52 | className?: string; 53 | shadow?: boolean; 54 | primary?: boolean; 55 | disabled?: boolean; 56 | type?: "button" | "submit" | "reset"; 57 | } 58 | 59 | const Button: React.SFC> = ({classes, className, type, onClick, children, primary, shadow, disabled}) => ( 60 | 68 | ) 69 | 70 | export default injectSheet(styles)(Button) 71 | -------------------------------------------------------------------------------- /view/components/Container.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import injectSheet, { WithStyles } from 'react-jss'; 3 | import classnames from 'classnames'; 4 | 5 | const styles = { 6 | container: { 7 | width: '100%', 8 | paddingRight: 30, 9 | paddingLeft: 30, 10 | marginRight: 'auto', 11 | marginLeft: 'auto', 12 | maxWidth: 1150 13 | }, 14 | [`@media (max-width: 1400px)`]: { 15 | container: { 16 | maxWidth: 920 17 | } 18 | }, 19 | [`@media (max-width: 1100px)`]: { 20 | container: { 21 | maxWidth: 690, 22 | paddingRight: 24, 23 | paddingLeft: 24 24 | } 25 | } 26 | }; 27 | 28 | const Container: React.SFC & {className?: string, style?: React.CSSProperties}> = ({className, classes, style, children}) => ( 29 |
30 | {children} 31 |
32 | ); 33 | 34 | export default injectSheet(styles)(Container); 35 | -------------------------------------------------------------------------------- /view/components/DraggableList/MoveContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import TemplateContainer from './TemplateContainer'; 3 | 4 | type Props = { 5 | item: I; 6 | template: T; 7 | padding: number; 8 | y?: number; 9 | itemSelected: number; 10 | anySelected: number; 11 | height: any; 12 | zIndex: number; 13 | makeDragHandleProps: (getY: () => number) => any; 14 | commonProps: C; 15 | }; 16 | export default class MoveContainer> extends React.Component> { 17 | _templateContainer: TemplateContainer; 18 | _templateContainerSetter = (cmp?: any) => { 19 | if (cmp) this._templateContainer = cmp; 20 | }; 21 | _el: HTMLElement; 22 | _elSetter = (el?: HTMLElement) => { 23 | if (el) this._el = el; 24 | }; 25 | 26 | getDOMNode(): HTMLElement { 27 | return this._el; 28 | } 29 | 30 | getTemplate(): T { 31 | return this._templateContainer.getTemplate(); 32 | } 33 | 34 | shouldComponentUpdate(nextProps: Props): boolean { 35 | return this.props.anySelected !== nextProps.anySelected || 36 | this.props.itemSelected !== nextProps.itemSelected || 37 | this.props.item !== nextProps.item || 38 | this.props.template !== nextProps.template || 39 | this.props.y !== nextProps.y || 40 | this.props.height !== nextProps.height || 41 | this.props.zIndex !== nextProps.zIndex || 42 | this.props.commonProps !== nextProps.commonProps; 43 | } 44 | 45 | _dragHandleProps = this.props.makeDragHandleProps(()=>this.props.y); 46 | 47 | render() { 48 | const { 49 | item, y, padding, itemSelected, anySelected, height, zIndex, template, commonProps 50 | } = this.props; 51 | 52 | return ( 53 |
67 | 76 |
77 | ); 78 | } 79 | } -------------------------------------------------------------------------------- /view/components/DraggableList/TemplateContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type Props = { 4 | item: I; 5 | template: T; 6 | itemSelected: number; 7 | anySelected: number; 8 | dragHandleProps: Object; 9 | commonProps: C; 10 | }; 11 | 12 | export default class TemplateContainer> extends React.Component> { 13 | _template: T; 14 | _templateSetter = (cmp: any) => { 15 | if (cmp) this._template = cmp; 16 | }; 17 | 18 | shouldComponentUpdate(nextProps: Props): boolean { 19 | return this.props.anySelected !== nextProps.anySelected || 20 | this.props.itemSelected !== nextProps.itemSelected || 21 | this.props.item !== nextProps.item || 22 | this.props.template !== nextProps.template || 23 | this.props.commonProps !== nextProps.commonProps; 24 | } 25 | 26 | getTemplate(): T { 27 | return this._template; 28 | } 29 | 30 | render() { 31 | const {item, itemSelected, anySelected, dragHandleProps, commonProps} = this.props; 32 | const Template = this.props.template as any; 33 | 34 | return ( 35 |