├── .config ├── eslint │ └── annotations_formatter.js └── husky │ └── pre-commit ├── .dockerignore ├── .gitattributes ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ └── config.yml ├── dependabot.yml ├── review-policy.yml └── workflows │ ├── codeql-analysis.yml │ ├── create_sentry_release.yml │ └── test_and_lint.yml ├── .gitignore ├── .swcrc ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── SECURITY.md ├── docker-compose.yaml ├── eslint.config.mjs ├── jest.config.js ├── package.json ├── public ├── _redirects ├── apple-touch-icon-precomposed.png ├── apple-touch-icon.png ├── branding │ ├── browserconfig.xml │ ├── logo192.png │ ├── logo256.png │ ├── logo512.png │ ├── mstile-144x144.png │ ├── mstile-150x150.png │ ├── mstile-310x150.png │ ├── mstile-310x310.png │ ├── mstile-70x70.png │ └── safari-pinned-tab.svg ├── favicon.ico ├── fonts │ └── unisans.otf ├── index.html ├── manifest.json └── robots.txt ├── src ├── App.tsx ├── api │ ├── auth.ts │ ├── client.ts │ ├── forms.ts │ └── question.ts ├── colors.ts ├── commonStyles.tsx ├── components │ ├── AuthorizationSplash.tsx │ ├── ErrorMessage.tsx │ ├── FormListing.tsx │ ├── HeaderBar │ │ ├── header_1.svg │ │ ├── header_2.svg │ │ ├── index.tsx │ │ └── logo.svg │ ├── InputTypes │ │ ├── Checkbox.tsx │ │ ├── Code.tsx │ │ ├── Radio.tsx │ │ ├── Range.tsx │ │ ├── Select.tsx │ │ ├── ShortText.tsx │ │ ├── TextArea.tsx │ │ ├── TimeZone.tsx │ │ ├── Vote.tsx │ │ └── index.tsx │ ├── Loading.tsx │ ├── OAuth2Button.tsx │ ├── Question.tsx │ ├── ScrollToTop.tsx │ └── Tag.tsx ├── globalStyles.ts ├── images │ └── logo.svg ├── index.tsx ├── pages │ ├── CallbackPage.tsx │ ├── FormPage │ │ ├── ErrorPage.tsx │ │ ├── FormPage.tsx │ │ ├── Navigation.tsx │ │ ├── SuccessPage.tsx │ │ └── submit.ts │ ├── LandingPage.tsx │ └── NotFound.tsx ├── react-app-env.d.ts ├── serviceWorker.ts ├── setupTests.ts ├── slices │ ├── authorization.ts │ └── votes.ts ├── store.ts ├── tests │ ├── App.test.tsx │ ├── __mocks__ │ │ └── svg.ts │ ├── api │ │ └── forms.test.ts │ ├── components │ │ ├── AuthorizationSplash.test.tsx │ │ ├── FormListing.test.tsx │ │ ├── HeaderBar.test.tsx │ │ ├── OAuth2Button.test.tsx │ │ └── Tag.test.tsx │ ├── globalStyles.test.ts │ ├── pages │ │ ├── CallbackPage.test.tsx │ │ ├── FormPage.test.tsx │ │ ├── FormPage │ │ │ └── Navigation.test.tsx │ │ └── LandingPage.test.tsx │ └── utils.tsx └── utils.ts ├── tsconfig.json ├── webpack.config.js └── yarn.lock /.config/eslint/annotations_formatter.js: -------------------------------------------------------------------------------- 1 | module.exports = function (results) { 2 | let output = ""; 3 | 4 | for (const file of results.filter(r => r.messages.length > 0)) { 5 | for (const message of file.messages) { 6 | const path = file.filePath.substr(process.cwd().length + 1); 7 | const severity = message.fatal || message.severity === 2 ? "error" : "warning"; 8 | const text = `[ESLint] ${message.ruleId}: ${message.message}`; 9 | output += `::${severity} file=${path},line=${message.line},col=${message.column}::${text}\n`; 10 | } 11 | } 12 | 13 | return output; 14 | }; 15 | -------------------------------------------------------------------------------- /.config/husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | yarn lint 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore Everything 2 | * 3 | 4 | # Include required files 5 | !public/ 6 | !src/ 7 | !.swcrc 8 | !package.json 9 | !tsconfig.json 10 | !webpack.config.js 11 | !yarn.lock 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.ico binary 3 | *.otf binary 4 | *.png binary 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @jb3 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: Notion task board 4 | url: https://www.notion.so/pythondiscord/2c44f303ec36425da23494f606973f14?v=39e0f16a1ffe4152b3dccb9d5bce9301 5 | about: Staff-only Notion task board for feature requests and bug reports. 6 | - name: Public GitHub Discussions 7 | url: https://github.com/python-discord/forms-frontend/discussions 8 | about: GitHub Discussions from external collaborators. 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /.github/review-policy.yml: -------------------------------------------------------------------------------- 1 | remote: python-discord/.github 2 | path: review-policies/forms.yml 3 | ref: main 4 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [main] 9 | schedule: 10 | - cron: '0 20 * * 2' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | fail-fast: false 19 | matrix: 20 | # Override automatic language detection by changing the below list 21 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 22 | language: ['javascript'] 23 | # Learn more... 24 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | with: 30 | # We must fetch at least the immediate parents so that if this is 31 | # a pull request then we can checkout the head. 32 | fetch-depth: 2 33 | 34 | # If this run was triggered by a pull request event, then checkout 35 | # the head of the pull request instead of the merge commit. 36 | - run: git checkout HEAD^2 37 | if: ${{ github.event_name == 'pull_request' }} 38 | 39 | # Initializes the CodeQL tools for scanning. 40 | - name: Initialize CodeQL 41 | uses: github/codeql-action/init@v1 42 | with: 43 | languages: ${{ matrix.language }} 44 | # If you wish to specify custom queries, you can do so here or in a config file. 45 | # By default, queries listed here will override any specified in a config file. 46 | # Prefix the list here with "+" to use these queries and those in the config file. 47 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 48 | 49 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 50 | # If this step fails, then you should remove it and run the build manually (see below) 51 | - name: Autobuild 52 | uses: github/codeql-action/autobuild@v1 53 | 54 | # ℹ️ Command-line programs to run using the OS shell. 55 | # 📚 https://git.io/JvXDl 56 | 57 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 58 | # and modify them (or add more) to build your code if your project 59 | # uses a compiled language 60 | 61 | #- run: | 62 | # make bootstrap 63 | # make release 64 | 65 | - name: Perform CodeQL Analysis 66 | uses: github/codeql-action/analyze@v1 67 | -------------------------------------------------------------------------------- /.github/workflows/create_sentry_release.yml: -------------------------------------------------------------------------------- 1 | name: Create Sentry release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | if: ${{ github.actor != 'dependabot[bot]' }} 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: EgorDm/gha-yarn-node-cache@v1 17 | 18 | - name: Install dependencies 19 | run: yarn install --prod 20 | 21 | - name: Set SHA 22 | id: commit-sha 23 | run: | 24 | if ${{ github.ref == 'refs/heads/main' }}; 25 | then echo "::set-output name=sha::${{ github.sha }}"; 26 | else echo "::set-output name=sha::${{ github.event.pull_request.head.sha }}"; 27 | fi; 28 | 29 | - name: Build application 30 | run: yarn build 31 | env: 32 | REACT_APP_SENTRY_DSN: ${{ secrets.SENTRY_DSN }} 33 | COMMIT_REF: ${{ steps.commit-sha.outputs.sha }} 34 | REACT_APP_OAUTH2_CLIENT_ID: ${{ secrets.CLIENT_ID }} 35 | 36 | - name: Create Sentry release (production) 37 | if: github.ref == 'refs/heads/main' 38 | uses: getsentry/action-release@v1 39 | env: 40 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 41 | SENTRY_ORG: python-discord 42 | SENTRY_PROJECT: forms-frontend 43 | with: 44 | environment: production 45 | sourcemaps: './build' 46 | version_prefix: forms-frontend@ 47 | 48 | - name: Create Sentry release (deploy preview) 49 | if: github.ref != 'refs/heads/main' 50 | uses: getsentry/action-release@v1 51 | env: 52 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 53 | SENTRY_ORG: python-discord 54 | SENTRY_PROJECT: forms-frontend 55 | with: 56 | environment: deploy-preview 57 | sourcemaps: './build' 58 | version_prefix: forms-frontend@ 59 | -------------------------------------------------------------------------------- /.github/workflows/test_and_lint.yml: -------------------------------------------------------------------------------- 1 | name: Test & Lint 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: EgorDm/gha-yarn-node-cache@v1 16 | 17 | - name: Install dependencies 18 | run: yarn install --prod 19 | 20 | - name: Build 21 | run: yarn build 22 | 23 | test: 24 | runs-on: ubuntu-latest 25 | 26 | steps: 27 | - uses: actions/checkout@v2 28 | - uses: EgorDm/gha-yarn-node-cache@v1 29 | 30 | - name: Install dependencies 31 | run: yarn install 32 | 33 | - name: Run tests 34 | run: yarn test 35 | 36 | lint: 37 | runs-on: ubuntu-latest 38 | 39 | steps: 40 | - uses: actions/checkout@v2 41 | - uses: EgorDm/gha-yarn-node-cache@v1 42 | 43 | - name: Install dependencies 44 | run: yarn install 45 | 46 | - name: Lint 47 | run: yarn run eslint --format .config/eslint/annotations_formatter.js src/ 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # linting 12 | .eslintcache 13 | 14 | # production 15 | /build 16 | 17 | # misc 18 | .DS_Store 19 | .env 20 | .env.local 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # Compose override files 30 | docker-compose.override.yml 31 | -------------------------------------------------------------------------------- /.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "jsc": { 3 | "parser": { 4 | "syntax": "typescript", 5 | "tsx": true, 6 | "dynamicImport": true 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to one of Our Projects 2 | 3 | The project is not currently accepting public contributions. 4 | Please contact `joe#6000` on Discord if you are interested in helping. 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-slim 2 | WORKDIR /app 3 | 4 | # Copy in lock files 5 | COPY package.json . 6 | COPY yarn.lock . 7 | 8 | # Install dependencies 9 | RUN yarn install 10 | 11 | # Copy program in 12 | COPY . . 13 | 14 | # Serve the frontend 15 | ENTRYPOINT ["yarn", "run"] 16 | CMD ["start", "--host", "0.0.0.0"] 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Python Discord 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Discord Forms Frontend 2 | The frontend component of the [Python Discord Forms](https://github.com/python-discord/forms-backend/) project. 3 | Backend available at: [python-discord/forms-backend](https://github.com/python-discord/forms-backend/). 4 | 5 | Our frontend is written in React.js and TypeScript. 6 | It's a fairly simple application to get your head around, 7 | with the whole thing broken down into smallish TypeScript components. 8 | 9 | ## Setup & Troubleshooting 10 | The project uses [yarn](https://yarnpkg.com/) for dependency and script management, 11 | and [webpack](https://webpack.js.org/) for building and development. 12 | 13 | A full setup guide is available on [Notion](https://pythondiscord.notion.site/Get-Started-30458bfb32ce4a0e9c489ea66daf0323), 14 | along with a troubleshooting guide.
15 | 16 | ## React & Dependency Info 17 | ### Project Info 18 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 19 | 20 | ### Available Scripts 21 | In the project directory, you can run: 22 | 23 | `yarn start` 24 | 25 | Runs the app in the development mode.
26 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 27 | 28 | The page will reload if you make edits.
29 | You will also see any lint errors in the console. 30 | 31 | `yarn test` 32 | 33 | Launches the test runner in the interactive watch mode.
34 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 35 | 36 | `yarn lint` 37 | 38 | Runs eslint on the src directory of the project.
39 | Code style can be found under [.eslintrc.json](.eslintrc.json). 40 | 41 | `yarn build` 42 | 43 | Builds the app for production to the `build` folder.
44 | It correctly bundles React in production mode and optimizes the build for the best performance. 45 | 46 | The build is minified and the filenames include the hashes.
47 | Your app is ready to be deployed! 48 | 49 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 50 | 51 | ### Learn More 52 | 53 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 54 | 55 | To learn React, check out the [React documentation](https://reactjs.org/). 56 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Notice 2 | 3 | The Security Notice for Python Discord projects can be found [on our website](https://pydis.com/security.md). 4 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | mongo: 3 | image: mongo:latest 4 | ports: 5 | - "127.0.0.1:27017:27017" 6 | environment: 7 | MONGO_INITDB_ROOT_USERNAME: forms-backend 8 | MONGO_INITDB_ROOT_PASSWORD: forms-backend 9 | MONGO_INITDB_DATABASE: pydis_forms 10 | 11 | snekbox: 12 | image: ghcr.io/python-discord/snekbox:latest 13 | ipc: none 14 | ports: 15 | - "127.0.0.1:8060:8060" 16 | privileged: true 17 | 18 | redis: 19 | image: redis:latest 20 | ports: 21 | - "127.0.0.1:6379:6379" 22 | 23 | backend: 24 | image: ghcr.io/python-discord/forms-backend 25 | command: ["uvicorn", "--reload", "--host", "0.0.0.0", "backend:app"] 26 | depends_on: 27 | - mongo 28 | - snekbox 29 | - redis 30 | ports: 31 | - "127.0.0.1:8000:8000" 32 | environment: 33 | - DATABASE_URL=mongodb://forms-backend:forms-backend@mongo:27017 34 | - SNEKBOX_URL=http://snekbox:8060/eval 35 | - OAUTH2_CLIENT_ID 36 | - OAUTH2_CLIENT_SECRET 37 | - ALLOWED_URL 38 | - DEBUG=true 39 | - PRODUCTION=false 40 | - REDIS_URL=redis://redis:6379 41 | env_file: 42 | - .env 43 | 44 | frontend: 45 | build: . 46 | depends_on: 47 | - backend 48 | volumes: 49 | - .:/app:ro 50 | - /app/node_modules # Ensure dependencies do not collide with a user's local install 51 | ports: 52 | - "3000:3000" 53 | environment: 54 | - BACKEND_URL=http://localhost:8000/ 55 | env_file: 56 | - .env 57 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; 2 | import react from "eslint-plugin-react"; 3 | import typescriptEslint from "@typescript-eslint/eslint-plugin"; 4 | import globals from "globals"; 5 | import tsParser from "@typescript-eslint/parser"; 6 | import path from "node:path"; 7 | import { fileURLToPath } from "node:url"; 8 | import js from "@eslint/js"; 9 | import { FlatCompat } from "@eslint/eslintrc"; 10 | 11 | const __filename = fileURLToPath(import.meta.url); 12 | const __dirname = path.dirname(__filename); 13 | const compat = new FlatCompat({ 14 | baseDirectory: __dirname, 15 | recommendedConfig: js.configs.recommended, 16 | allConfig: js.configs.all 17 | }); 18 | 19 | export default [...fixupConfigRules(compat.extends( 20 | "eslint:recommended", 21 | "plugin:react/recommended", 22 | "plugin:@typescript-eslint/recommended", 23 | )), { 24 | plugins: { 25 | react: fixupPluginRules(react), 26 | "@typescript-eslint": fixupPluginRules(typescriptEslint), 27 | }, 28 | 29 | languageOptions: { 30 | globals: { 31 | ...globals.browser, 32 | }, 33 | 34 | parser: tsParser, 35 | ecmaVersion: 12, 36 | sourceType: "module", 37 | 38 | parserOptions: { 39 | ecmaFeatures: { 40 | jsx: true, 41 | }, 42 | }, 43 | }, 44 | 45 | settings: { 46 | react: { 47 | pragma: "jsx", 48 | version: "detect", 49 | flowVersion: "0.53", 50 | }, 51 | }, 52 | 53 | rules: { 54 | indent: ["error", 4, { 55 | SwitchCase: 1, 56 | }], 57 | 58 | quotes: ["error", "double"], 59 | semi: ["error", "always"], 60 | "no-trailing-spaces": "error", 61 | "eol-last": "error", 62 | "linebreak-style": "off", 63 | "react/no-unknown-property": ["error", { "ignore": ["css"] }] 64 | }, 65 | }, { 66 | files: ["**/*.test.ts*"], 67 | 68 | rules: { 69 | "react/react-in-jsx-scope": "off" 70 | }, 71 | }]; 72 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.(t|j)sx?$': ['@swc/jest'], 4 | }, 5 | moduleNameMapper: { 6 | '\\.svg$': '/src/tests/__mocks__/svg.ts', 7 | }, 8 | collectCoverageFrom: [ 9 | "src/**/*.{js,jsx,ts,tsx}", 10 | "!**/node_modules/**", 11 | ], 12 | collectCoverage: true, 13 | coverageProvider: "v8", 14 | setupFilesAfterEnv: ["./src/setupTests.ts"], 15 | testEnvironment: "jest-environment-jsdom" 16 | } 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pydis-forms-frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "engines": { 6 | "node": "18.* || 20.*" 7 | }, 8 | "license": "MIT", 9 | "dependencies": { 10 | "@codemirror/lang-python": "^6.1.6", 11 | "@emotion/react": "11.11.4", 12 | "@fortawesome/fontawesome-svg-core": "6.5.2", 13 | "@fortawesome/free-brands-svg-icons": "6.5.2", 14 | "@fortawesome/free-solid-svg-icons": "6.5.2", 15 | "@fortawesome/react-fontawesome": "0.2.2", 16 | "@reduxjs/toolkit": "^2.2.6", 17 | "@sentry/react": "8.17.0", 18 | "@svgr/webpack": "8.1.0", 19 | "@swc/core": "1.6.13", 20 | "@uiw/codemirror-theme-atomone": "^4.23.0", 21 | "@uiw/react-codemirror": "^4.23.0", 22 | "axios": "1.7.2", 23 | "copy-webpack-plugin": "12.0.2", 24 | "fs-extra": "11.2.0", 25 | "html-webpack-plugin": "5.6.0", 26 | "identity-obj-proxy": "3.0.0", 27 | "react": "18.3.1", 28 | "react-app-polyfill": "3.0.0", 29 | "react-dom": "18.3.1", 30 | "react-flip-move": "^3.0.5", 31 | "react-redux": "^9.1.2", 32 | "react-router-dom": "6.24.1", 33 | "react-spinners": "0.14.1", 34 | "react-transition-group": "4.4.5", 35 | "smoothscroll-polyfill": "0.4.4", 36 | "swc-loader": "0.2.6", 37 | "typescript": "5.5.3", 38 | "universal-cookie": "7.1.4", 39 | "webpack": "5.93.0", 40 | "webpack-cli": "5.1.4", 41 | "webpack-manifest-plugin": "5.0.0", 42 | "workbox-webpack-plugin": "7.1.0" 43 | }, 44 | "scripts": { 45 | "start": "webpack serve --node-env=development", 46 | "build": "webpack", 47 | "test": "jest", 48 | "lint": "eslint --cache src/", 49 | "prepare": "husky install .config/husky" 50 | }, 51 | "eslintConfig": { 52 | "extends": "react-app" 53 | }, 54 | "browserslist": { 55 | "production": [ 56 | ">0.2%", 57 | "not dead", 58 | "not op_mini all" 59 | ], 60 | "development": [ 61 | "last 1 chrome version", 62 | "last 1 firefox version", 63 | "last 1 safari version" 64 | ] 65 | }, 66 | "devDependencies": { 67 | "@eslint/compat": "^1.1.1", 68 | "@eslint/eslintrc": "^3.1.0", 69 | "@eslint/js": "^9.6.0", 70 | "@swc/jest": "0.2.36", 71 | "@testing-library/dom": "^10.3.1", 72 | "@testing-library/jest-dom": "6.4.6", 73 | "@testing-library/react": "16.0.0", 74 | "@testing-library/user-event": "14.5.2", 75 | "@types/jest": "29.5.12", 76 | "@types/node": "20.14.10", 77 | "@types/react": "18.3.3", 78 | "@types/react-dom": "18.3.0", 79 | "@types/react-router-dom": "5.3.3", 80 | "@types/react-transition-group": "4.4.10", 81 | "@types/smoothscroll-polyfill": "0.3.4", 82 | "@typescript-eslint/eslint-plugin": "7.16.0", 83 | "@typescript-eslint/parser": "7.16.0", 84 | "dotenv": "16.4.5", 85 | "eslint": "9.6.0", 86 | "eslint-plugin-react": "7.34.3", 87 | "globals": "^15.8.0", 88 | "husky": "9.0.11", 89 | "jest": "29.7.0", 90 | "jest-environment-jsdom": "29.7.0", 91 | "jest-resolve": "29.7.0", 92 | "jest-watch-typeahead": "2.2.2", 93 | "webpack-dev-server": "5.0.4" 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/forms-frontend/cb80c2c2126f8bedd5cb0569e31e1ed6a373c531/public/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/forms-frontend/cb80c2c2126f8bedd5cb0569e31e1ed6a373c531/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/branding/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #2d89ef 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/branding/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/forms-frontend/cb80c2c2126f8bedd5cb0569e31e1ed6a373c531/public/branding/logo192.png -------------------------------------------------------------------------------- /public/branding/logo256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/forms-frontend/cb80c2c2126f8bedd5cb0569e31e1ed6a373c531/public/branding/logo256.png -------------------------------------------------------------------------------- /public/branding/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/forms-frontend/cb80c2c2126f8bedd5cb0569e31e1ed6a373c531/public/branding/logo512.png -------------------------------------------------------------------------------- /public/branding/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/forms-frontend/cb80c2c2126f8bedd5cb0569e31e1ed6a373c531/public/branding/mstile-144x144.png -------------------------------------------------------------------------------- /public/branding/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/forms-frontend/cb80c2c2126f8bedd5cb0569e31e1ed6a373c531/public/branding/mstile-150x150.png -------------------------------------------------------------------------------- /public/branding/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/forms-frontend/cb80c2c2126f8bedd5cb0569e31e1ed6a373c531/public/branding/mstile-310x150.png -------------------------------------------------------------------------------- /public/branding/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/forms-frontend/cb80c2c2126f8bedd5cb0569e31e1ed6a373c531/public/branding/mstile-310x310.png -------------------------------------------------------------------------------- /public/branding/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/forms-frontend/cb80c2c2126f8bedd5cb0569e31e1ed6a373c531/public/branding/mstile-70x70.png -------------------------------------------------------------------------------- /public/branding/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/forms-frontend/cb80c2c2126f8bedd5cb0569e31e1ed6a373c531/public/favicon.ico -------------------------------------------------------------------------------- /public/fonts/unisans.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/python-discord/forms-frontend/cb80c2c2126f8bedd5cb0569e31e1ed6a373c531/public/fonts/unisans.otf -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | Python Discord Forms 19 | 20 | 21 | 22 |
23 | 24 | 25 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "PyDis Forms", 3 | "name": "Python Discord Forms", 4 | "icons": [ 5 | { 6 | "src": "/branding/logo192.png", 7 | "type": "image/png", 8 | "sizes": "192x192" 9 | }, 10 | { 11 | "src": "/branding/logo256.png", 12 | "type": "image/png", 13 | "sizes": "256x256" 14 | }, 15 | { 16 | "src": "/branding/logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#7289DA", 24 | "background_color": "#23272A" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | /** @global location */ 3 | import React, { Suspense } from "react"; 4 | import { jsx, css, Global } from "@emotion/react"; 5 | 6 | import { BrowserRouter, Route, Routes } from "react-router-dom"; 7 | 8 | import { PropagateLoader } from "react-spinners"; 9 | 10 | import AuthorizationSplash from "./components/AuthorizationSplash"; 11 | 12 | import { CSSTransition, TransitionGroup } from "react-transition-group"; 13 | 14 | import Logo from "./images/logo.svg"; 15 | 16 | import globalStyles from "./globalStyles"; 17 | import NotFound from "./pages/NotFound"; 18 | 19 | const LandingPage = React.lazy(() => import("./pages/LandingPage")); 20 | const FormPage = React.lazy(() => import("./pages/FormPage/FormPage")); 21 | const CallbackPage = React.lazy(() => import("./pages/CallbackPage")); 22 | 23 | const routes = [ 24 | { path: "/", Component: LandingPage }, 25 | { path: "/form/:id", Component: FormPage}, 26 | { path: "/callback", Component: CallbackPage } 27 | ]; 28 | 29 | const pageLoadingStyles = css` 30 | display: flex; 31 | justify-content: center; 32 | margin-top: 10%; 33 | align-items: center; 34 | flex-direction: column; 35 | 36 | svg { 37 | transform: scale(0.35); 38 | } 39 | `; 40 | 41 | function PageLoading() { 42 | return
43 | 44 | 45 |
; 46 | } 47 | 48 | function Routing(): JSX.Element { 49 | const renderedRoutes = routes.map(({path, Component}) => ( 50 | }> 52 | }/> 53 | )); 54 | 55 | return ( 56 | 57 | {renderedRoutes} 58 | }/> 59 | 60 | ); 61 | } 62 | 63 | function App(): JSX.Element { 64 | return ( 65 |
66 | 67 | 68 | 69 | 70 | 71 | 72 | }/> 73 | 74 | 75 | 76 | 77 |
78 | ); 79 | } 80 | 81 | export default App; 82 | -------------------------------------------------------------------------------- /src/api/auth.ts: -------------------------------------------------------------------------------- 1 | import Cookies, { CookieSetOptions } from "universal-cookie"; 2 | import { AxiosResponse } from "axios"; 3 | 4 | import { startAuthorizing, finishAuthorizing } from "../slices/authorization"; 5 | import formsStore from "../store"; 6 | 7 | import * as Sentry from "@sentry/react"; 8 | 9 | import APIClient from "./client"; 10 | 11 | const OAUTH2_CLIENT_ID = process.env.REACT_APP_OAUTH2_CLIENT_ID; 12 | const PRODUCTION = process.env.NODE_ENV !== "development"; 13 | const STATE_LENGTH = 64; 14 | 15 | /** 16 | * Authorization result as returned from the backend. 17 | */ 18 | interface AuthResult { 19 | username: string, 20 | expiry: string 21 | } 22 | 23 | /** 24 | * Name properties for authorization cookies. 25 | */ 26 | export enum CookieNames { 27 | Scopes = "DiscordOAuthScopes", 28 | Username = "DiscordUsername" 29 | } 30 | 31 | export interface APIErrors { 32 | Message: APIErrorMessages, 33 | Error: any, /* eslint-disable-line @typescript-eslint/no-explicit-any */ 34 | } 35 | 36 | export enum APIErrorMessages { 37 | BackendValidation = "Backend could not authorize with Discord. Please contact the forms team.", 38 | BackendValidationDev = "Backend could not authorize with Discord, possibly due to being on a preview branch. Please contact the forms team.", 39 | BackendUnresponsive = "Unable to reach the backend, please retry, or contact the forms team.", 40 | BadResponse = "The server returned a bad response, please contact the forms team.", 41 | AccessRejected = "Authorization was cancelled.", 42 | Unknown = "An unknown error occurred, please contact the forms team." 43 | } 44 | 45 | /** 46 | * [Reference]{@link https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes} 47 | * 48 | * Commented out enums are locked behind whitelists. 49 | */ 50 | export enum OAuthScopes { 51 | Connections = "connections", 52 | Email = "email", 53 | Identify = "identify", 54 | Guilds = "guilds" 55 | } 56 | 57 | /** 58 | * Helper method to ensure the minimum required scopes 59 | * for the application to function exist in a list. 60 | */ 61 | function ensureMinimumScopes(scopes: unknown, expected: OAuthScopes | OAuthScopes[]): OAuthScopes[] { 62 | let result: OAuthScopes[] = []; 63 | if (scopes && Array.isArray(scopes)) { 64 | result = scopes; 65 | } 66 | 67 | if (Array.isArray(expected)) { 68 | expected.forEach(scope => { if (!result.includes(scope)) result.push(scope); }); 69 | } else { 70 | if (!result.includes(expected)) result.push(expected); 71 | } 72 | 73 | return result; 74 | } 75 | 76 | /** 77 | * Return true if the program has the requested scopes or higher. 78 | */ 79 | export function checkScopes(scopes?: OAuthScopes[]): boolean { 80 | const cleanedScopes = ensureMinimumScopes(scopes, OAuthScopes.Identify); 81 | 82 | // Get Active Scopes And Ensure Type 83 | const cookies = new Cookies().get(CookieNames.Scopes); 84 | if (!cookies || !Array.isArray(cookies)) { 85 | return false; 86 | } 87 | 88 | // Check For Scope Existence 89 | for (const scope of cleanedScopes) { 90 | if (!cookies.includes(scope)) { 91 | return false; 92 | } 93 | } 94 | 95 | return true; 96 | } 97 | 98 | /*** 99 | * Request authorization code from the discord api with the provided scopes. 100 | * 101 | * @returns {code, cleanedScopes} The discord authorization code and the scopes the code is granted for. 102 | * @throws {Error} Indicates that an integrity check failed. 103 | */ 104 | export async function getDiscordCode(scopes: OAuthScopes[], disableFunction?: (disable: boolean) => void): Promise<{code: string | null, cleanedScopes: OAuthScopes[]}> { 105 | const cleanedScopes = ensureMinimumScopes(scopes, OAuthScopes.Identify); 106 | 107 | // Generate a new user state 108 | const stateBytes = new Uint8Array(STATE_LENGTH); 109 | crypto.getRandomValues(stateBytes); 110 | 111 | let state = ""; 112 | for (let i = 0; i < stateBytes.length; i++) { 113 | state += stateBytes[i].toString(16).padStart(2, "0"); 114 | } 115 | 116 | const scopeString = encodeURIComponent(cleanedScopes.join(" ")); 117 | const redirectURI = encodeURIComponent(document.location.protocol + "//" + document.location.host + "/callback"); 118 | 119 | const windowHeight = screen.availHeight; 120 | const windowWidth = screen.availWidth; 121 | const requestHeight = Math.floor(windowHeight * 0.75); 122 | const requestWidth = Math.floor(windowWidth * 0.4); 123 | 124 | // Open login window 125 | const windowRef = window.open( 126 | `https://discord.com/api/oauth2/authorize?client_id=${OAUTH2_CLIENT_ID}&state=${state}&response_type=code&scope=${scopeString}&redirect_uri=${redirectURI}&prompt=consent`, 127 | "_blank", 128 | `popup=true,height=${requestHeight},left=0,top=0,width=${requestWidth}` 129 | ); 130 | 131 | formsStore.dispatch(startAuthorizing()); 132 | 133 | // Clean up on login 134 | const interval = setInterval(() => { 135 | if (windowRef?.closed) { 136 | clearInterval(interval); 137 | formsStore.dispatch(finishAuthorizing()); 138 | if (disableFunction) { disableFunction(false); } 139 | } 140 | }, 500); 141 | 142 | // Handle response 143 | const code = await new Promise(resolve => { 144 | window.onmessage = (message: MessageEvent) => { 145 | if (message.data.source) { 146 | // React DevTools has a habit of sending messages, ignore them. 147 | return; 148 | } 149 | 150 | if (message.data.pydis_source !== "oauth2_callback") { 151 | // Ignore messages not from the callback 152 | return; 153 | } 154 | 155 | if (message.isTrusted) { 156 | windowRef?.close(); 157 | 158 | formsStore.dispatch(finishAuthorizing()); 159 | 160 | clearInterval(interval); 161 | 162 | // State integrity check 163 | if (message.data.state !== state.toString()) { 164 | // This indicates a lack of integrity 165 | throw Error(`Integrity check failed. Expected state of ${state}, received ${JSON.stringify(message.data)}`); 166 | } 167 | 168 | // Remove handler 169 | window.onmessage = null; 170 | resolve(message.data.code); 171 | } 172 | }; 173 | }); 174 | 175 | return {code: code, cleanedScopes: cleanedScopes}; 176 | } 177 | 178 | /** 179 | * Sends a discord code to the backend, which sets an authentication JWT 180 | * and returns the Discord username. 181 | * 182 | * @throws { APIErrors } On error, the APIErrors.Message is set, and an APIErrors object is thrown. 183 | */ 184 | export async function requestBackendJWT(code: string): Promise<{username: string, maxAge: number}> { 185 | const reason: APIErrors = { Message: APIErrorMessages.Unknown, Error: null }; 186 | let result; 187 | 188 | try { 189 | result = await APIClient.post("/auth/authorize", {token: code}) 190 | .catch(error => { 191 | reason.Error = error; 192 | 193 | if (error.response) { 194 | // API Responded with a non-2xx Response 195 | if (error.response.status === 400) { 196 | reason.Message = process.env.CONTEXT === "deploy-preview" ? APIErrorMessages.BackendValidationDev : APIErrorMessages.BackendValidation; 197 | } 198 | } else if (error.request) { 199 | // API did not respond 200 | reason.Message = APIErrorMessages.BackendUnresponsive; 201 | } 202 | 203 | throw error; 204 | 205 | }).then((response: AxiosResponse) => { 206 | const expiry = Date.parse(response.data.expiry); 207 | return {username: response.data.username, maxAge: (expiry - Date.now()) / 1000}; 208 | }); 209 | } catch (e) { 210 | if (reason.Error === null) { 211 | reason.Error = e; 212 | } 213 | 214 | throw reason; 215 | } 216 | 217 | if (!result || !result.username || !result.maxAge) { 218 | reason.Message = APIErrorMessages.BadResponse; 219 | throw reason; 220 | } 221 | 222 | return result; 223 | } 224 | 225 | /** 226 | * Refresh the backend authentication JWT. Returns the success of the operation, and silently handles denied requests. 227 | */ 228 | export async function refreshBackendJWT(): Promise { 229 | const cookies = new Cookies(); 230 | 231 | let pass = true; 232 | APIClient.post("/auth/refresh").then((response: AxiosResponse) => { 233 | cookies.set(CookieNames.Username, response.data.username, {sameSite: "strict", secure: PRODUCTION, path: "/", expires: new Date(3000, 1)}); 234 | 235 | Sentry.setUser({ 236 | username: response.data.username 237 | }); 238 | 239 | const expiry = Date.parse(response.data.expiry); 240 | setTimeout(refreshBackendJWT, ((expiry - Date.now()) / 1000 * 0.9)); 241 | }).catch(() => { 242 | pass = false; 243 | cookies.remove(CookieNames.Scopes, {path: "/"}); 244 | }); 245 | 246 | return new Promise(resolve => resolve(pass)); 247 | } 248 | 249 | /** 250 | * Clear the auth state. 251 | */ 252 | export function clearAuth(): void { 253 | const cookies = new Cookies(); 254 | Sentry.setUser(null); 255 | cookies.remove(CookieNames.Scopes, {path: "/"}); 256 | cookies.remove(CookieNames.Username, {path: "/"}); 257 | } 258 | 259 | /** 260 | * Handle a full authorization flow. Sets a cookie with the JWT and scopes. 261 | * 262 | * @param scopes The scopes that should be authorized for the application. 263 | * @param disableFunction An optional function that can disable a component while processing. 264 | * @param refresh If true, the token refresh will be scehduled automatically 265 | * 266 | * @throws { APIErrors } See documentation on { requestBackendJWT }. 267 | */ 268 | export default async function authorize(scopes: OAuthScopes[] = [], disableFunction?: (newState: boolean) => void, refresh = true): Promise { 269 | if (checkScopes(scopes)) { 270 | return; 271 | } 272 | 273 | const cookies = new Cookies; 274 | cookies.remove(CookieNames.Scopes, {path: "/"}); 275 | 276 | if (disableFunction) { disableFunction(true); } 277 | await getDiscordCode(scopes, disableFunction).then(async discord_response =>{ 278 | if (!discord_response.code) { 279 | throw { 280 | Message: APIErrorMessages.AccessRejected, 281 | Error: null 282 | }; 283 | } 284 | await requestBackendJWT(discord_response.code).then(backend_response => { 285 | const options: CookieSetOptions = {sameSite: "strict", secure: PRODUCTION, path: "/", expires: new Date(3000, 1)}; 286 | cookies.set(CookieNames.Username, backend_response.username, options); 287 | 288 | Sentry.setUser({ 289 | username: backend_response.username, 290 | }); 291 | 292 | options.maxAge = backend_response.maxAge; 293 | cookies.set(CookieNames.Scopes, discord_response.cleanedScopes, options); 294 | 295 | if (refresh) { 296 | // Schedule refresh after 90% of it's age 297 | setTimeout(refreshBackendJWT, (backend_response.maxAge * 0.9) * 1000); 298 | } 299 | }); 300 | }).finally(() => { 301 | if (disableFunction) { disableFunction(false); } 302 | }); 303 | 304 | 305 | return new Promise(resolve => resolve()); 306 | } 307 | -------------------------------------------------------------------------------- /src/api/client.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | 4 | export default axios.create({ 5 | baseURL: process.env.BACKEND_URL, 6 | withCredentials: true 7 | }); 8 | -------------------------------------------------------------------------------- /src/api/forms.ts: -------------------------------------------------------------------------------- 1 | import { Question } from "./question"; 2 | import ApiClient from "./client"; 3 | 4 | export enum FormFeatures { 5 | Discoverable = "DISCOVERABLE", 6 | RequiresLogin = "REQUIRES_LOGIN", 7 | Open = "OPEN", 8 | CollectEmail = "COLLECT_EMAIL", 9 | DisableAntispam = "DISABLE_ANTISPAM", 10 | WebhookEnabled = "WEBHOOK_ENABLED" 11 | } 12 | 13 | export interface Form { 14 | id: string, 15 | features: Array, 16 | webhook: WebHook | null, 17 | questions: Array, 18 | name: string, 19 | description: string, 20 | submitted_text: string | null 21 | } 22 | 23 | export interface WebHook { 24 | url: string, 25 | message: string | null 26 | } 27 | 28 | export async function getForms(): Promise { 29 | const fetch_response = await ApiClient.get("forms/discoverable"); 30 | return fetch_response.data; 31 | } 32 | 33 | export async function getForm(id: string): Promise
{ 34 | const fetch_response = await ApiClient.get(`forms/${id}`); 35 | return fetch_response.data; 36 | } 37 | -------------------------------------------------------------------------------- /src/api/question.ts: -------------------------------------------------------------------------------- 1 | export enum QuestionType { 2 | TextArea = "textarea", 3 | Checkbox = "checkbox", 4 | Radio = "radio", 5 | Code = "code", 6 | Select = "select", 7 | ShortText = "short_text", 8 | Range = "range", 9 | Section = "section", 10 | TimeZone = "timezone", 11 | Vote = "vote" 12 | } 13 | 14 | export interface Question { 15 | id: string, 16 | name: string, 17 | type: QuestionType, 18 | data: { [key: string]: string | string[] }, 19 | required: boolean 20 | } 21 | 22 | type UnittestError = { 23 | question_id: string, 24 | question_index: number, 25 | return_code: number, 26 | passed: boolean, 27 | result: string, 28 | } 29 | 30 | export interface UnittestFailure { 31 | error: string, 32 | test_results: UnittestError[], 33 | } 34 | -------------------------------------------------------------------------------- /src/colors.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | blurple: "#7289DA", 3 | darkerBlurple: "#4E609C", 4 | darkButNotBlack: "#2C2F33", 5 | notQuiteBlack: "#23272A", 6 | greyple: "#99AAB5", 7 | darkerGreyple: "#6E7D88", 8 | error: "#f04747", 9 | success: "#37805e" 10 | }; 11 | -------------------------------------------------------------------------------- /src/commonStyles.tsx: -------------------------------------------------------------------------------- 1 | import { css } from "@emotion/react"; 2 | import colors from "./colors"; 3 | 4 | const selectable = css` 5 | -moz-user-select: text; 6 | -webkit-user-select: text; 7 | -ms-user-select: text; 8 | user-select: text; 9 | `; 10 | 11 | const unselectable = css` 12 | -moz-user-select: none; 13 | -webkit-user-select: none; 14 | -ms-user-select: none; 15 | user-select: none; 16 | `; 17 | 18 | const hiddenInput = css` 19 | position: absolute; 20 | opacity: 0; 21 | height: 0; 22 | width: 0; 23 | `; 24 | 25 | const multiSelectInput = css` 26 | display: inline-block; 27 | position: relative; 28 | 29 | margin: 1rem 0.5rem 0 0; 30 | border: whitesmoke 0.2rem solid; 31 | 32 | background-color: whitesmoke; 33 | transition: background-color 200ms; 34 | `; 35 | 36 | const textInputs = css` 37 | display: inline-block; 38 | width: min(20rem, 90%); 39 | height: 100%; 40 | min-height: 2rem; 41 | 42 | background: whitesmoke; 43 | 44 | color: black; 45 | padding: 0.15rem 1rem 0 1rem; 46 | font: inherit; 47 | 48 | margin-bottom: 0; 49 | 50 | border: 0.1rem solid black; 51 | border-radius: 8px; 52 | `; 53 | 54 | const actionButtonStyles = css` 55 | white-space: nowrap; 56 | 57 | button:disabled { 58 | background-color: ${colors.greyple}; 59 | cursor: default; 60 | } 61 | 62 | button { 63 | width: 100%; 64 | cursor: pointer; 65 | 66 | border: none; 67 | border-radius: 8px; 68 | 69 | color: white; 70 | font: inherit; 71 | 72 | background-color: ${colors.blurple}; 73 | transition: background-color 300ms; 74 | } 75 | 76 | button[type="submit"] { 77 | padding: 0.55rem 4.25rem; 78 | } 79 | 80 | button:enabled:hover { 81 | background-color: ${colors.darkerBlurple}; 82 | } 83 | `; 84 | 85 | const invalidStyles = css` 86 | .invalid-box { 87 | -webkit-appearance: none; 88 | -webkit-box-shadow: 0 0 0.6rem ${colors.error}; 89 | box-shadow: 0 0 0.6rem ${colors.error}; 90 | border-color: transparent; 91 | } 92 | `; 93 | 94 | const mainTextStyles = css` 95 | margin: auto; 96 | width: 50%; 97 | 98 | text-align: center; 99 | font-size: 1.5rem; 100 | 101 | > div { 102 | margin: 2rem auto; 103 | } 104 | 105 | @media (max-width: 800px) { 106 | width: 80%; 107 | } 108 | `; 109 | 110 | const navigationStyles = css` 111 | display: flex; 112 | flex-direction: row; 113 | justify-content: space-around; 114 | align-items: center; 115 | flex-wrap: wrap; 116 | 117 | column-gap: 20px; 118 | row-gap: 20px; 119 | 120 | > * { 121 | // Make all elements the same size 122 | flex: 0 1 16rem; 123 | } 124 | `; 125 | 126 | const returnButtonStyles = css` 127 | font-size: 1.5rem; 128 | text-align: center; 129 | 130 | color: white; 131 | text-decoration: none; 132 | background-color: ${colors.greyple}; 133 | 134 | padding: 0.5rem 0; 135 | border-radius: 8px; 136 | 137 | transition: background-color 300ms; 138 | 139 | :hover { 140 | background-color: ${colors.darkerGreyple}; 141 | } 142 | `; 143 | 144 | export { 145 | selectable, 146 | unselectable, 147 | hiddenInput, 148 | multiSelectInput, 149 | textInputs, 150 | actionButtonStyles, 151 | invalidStyles, 152 | mainTextStyles, 153 | returnButtonStyles, 154 | navigationStyles, 155 | }; 156 | -------------------------------------------------------------------------------- /src/components/AuthorizationSplash.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { css, jsx } from "@emotion/react"; 3 | import { useSelector } from "react-redux"; 4 | import { type RootState } from "../store"; 5 | 6 | const splashStyles = css` 7 | position: fixed; 8 | width: 100%; 9 | height: 100%; 10 | top: 0; 11 | transition: background-color 0.5s ease, opacity 0.5s ease; 12 | `; 13 | 14 | const innerText = css` 15 | text-align: center; 16 | vertical-align: middle; 17 | `; 18 | 19 | const spacer = css` 20 | height: 30%; 21 | `; 22 | 23 | function AuthorizationSplash(): JSX.Element { 24 | const authorizing = useSelector(state => state.authorization.authorizing); 25 | 26 | const background = `rgba(0, 0, 0, ${authorizing ? "0.90" : "0"})`; 27 | 28 | return
34 |
35 |
36 |

Authorization in progress

37 |

Login with Discord in the opened window and return to this tab once complete.

38 |
39 |
; 40 | } 41 | 42 | export default AuthorizationSplash; 43 | -------------------------------------------------------------------------------- /src/components/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import {jsx, css} from "@emotion/react"; 3 | import colors from "../colors"; 4 | import {selectable} from "../commonStyles"; 5 | 6 | interface ErrorMessageProps { 7 | show: boolean, 8 | content: string | JSX.Element, 9 | } 10 | 11 | export default function ErrorMessage(props: ErrorMessageProps): JSX.Element | null { 12 | const styles = css` 13 | color: ${colors.error}; 14 | font-size: 1.15rem; 15 | line-height: 1.1rem; 16 | margin: 1rem 0 0; 17 | 18 | visibility: ${props.show ? "visible" : "hidden"}; 19 | opacity: ${props.show ? 1 : 0}; 20 | transition: opacity 200ms, visibility 200ms; 21 | `; 22 | 23 | // These styles are not applied when content is an element; 24 | const floatingStyles = css` 25 | position: absolute; 26 | z-index: -1; 27 | `; 28 | 29 | const isString = typeof props.content === "string"; 30 | 31 | return ( 32 |
33 | {props.content} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/components/FormListing.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { css, jsx } from "@emotion/react"; 3 | import { Link } from "react-router-dom"; 4 | 5 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 6 | import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; 7 | 8 | import Tag from "./Tag"; 9 | 10 | import colors from "../colors"; 11 | 12 | import { Form, FormFeatures } from "../api/forms"; 13 | 14 | interface FormListingProps { 15 | form: Form 16 | } 17 | 18 | function FormListing({ form }: FormListingProps): JSX.Element { 19 | const listingStyle = css` 20 | background-color: ${form.features.includes(FormFeatures.Open) ? colors.success : colors.darkButNotBlack}; 21 | width: 60%; 22 | padding: 20px; 23 | margin-top: 20px; 24 | margin-bottom: 20px; 25 | border-radius: 10px; 26 | transition-property: transform, width; 27 | transition-duration: 500ms; 28 | text-decoration: none; 29 | color: inherit; 30 | 31 | @media (max-width: 575px) { 32 | width: 80%; 33 | } 34 | 35 | &:hover { 36 | transform: scale(1.03); 37 | } 38 | `; 39 | 40 | let closedTag; 41 | 42 | if (!form.features.includes(FormFeatures.Open)) { 43 | closedTag = ; 44 | } 45 | 46 | return 47 |
48 |

{closedTag}{form.name}

49 |

{form.description}

50 |
51 | ; 52 | } 53 | 54 | export default FormListing; 55 | -------------------------------------------------------------------------------- /src/components/HeaderBar/header_1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/HeaderBar/header_2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/components/HeaderBar/index.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx, css } from "@emotion/react"; 3 | 4 | import Header1 from "./header_1.svg"; 5 | import Header2 from "./header_2.svg"; 6 | import Logo from "./logo.svg"; 7 | 8 | import { Link } from "react-router-dom"; 9 | 10 | interface HeaderBarProps { 11 | title?: string 12 | description?: string 13 | } 14 | 15 | const headerImageStyles = css` 16 | * { 17 | z-index: -1; 18 | top: 0; 19 | position: absolute; 20 | width: 100%; 21 | transition: height 1s; 22 | } 23 | `; 24 | 25 | const headerTextStyles = css` 26 | transition: margin 1s; 27 | font-family: "Uni Sans", "Hind", "Arial", sans-serif; 28 | 29 | margin: 0 2rem 10rem 2rem; 30 | 31 | .title { 32 | font-size: 3vmax; 33 | margin-bottom: 0; 34 | } 35 | 36 | .full_size { 37 | line-height: 200%; 38 | } 39 | 40 | .description { 41 | font-size: 1.5vmax; 42 | } 43 | 44 | .title, .description { 45 | transition: font-size 1s; 46 | } 47 | 48 | @media (max-width: 480px) { 49 | margin-top: 7rem; 50 | text-align: center; 51 | 52 | .title { 53 | font-size: 5vmax; 54 | } 55 | 56 | .full_size { 57 | line-height: 100%; 58 | } 59 | 60 | .description { 61 | font-size: 2vmax; 62 | } 63 | } 64 | `; 65 | 66 | const homeButtonStyles = css` 67 | svg { 68 | transform: scale(0.25); 69 | transition: top 300ms, transform 300ms; 70 | 71 | @media (max-width: 480px) { 72 | transform: scale(0.15); 73 | } 74 | } 75 | 76 | * { 77 | position: absolute; 78 | top: -10rem; 79 | right: 1rem; 80 | 81 | z-index: 0; 82 | transform-origin: right; 83 | 84 | @media (max-width: 700px) { 85 | top: -11.5rem; 86 | } 87 | 88 | @media (max-width: 480px) { 89 | top: -12.5rem; 90 | } 91 | } 92 | `; 93 | 94 | function HeaderBar({ title, description }: HeaderBarProps): JSX.Element { 95 | if (!title) { 96 | title = "Python Discord Forms"; 97 | } 98 | 99 | return ( 100 |
101 |
102 | 103 | 104 |
105 | 106 |
107 |

{title}

108 | {description ?

{description}

: null} 109 |
110 | 111 | 112 | 113 | 114 |
115 | ); 116 | } 117 | 118 | export default HeaderBar; 119 | -------------------------------------------------------------------------------- /src/components/HeaderBar/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 3 | 4 | -------------------------------------------------------------------------------- /src/components/InputTypes/Checkbox.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx, css } from "@emotion/react"; 3 | import React, { ChangeEvent } from "react"; 4 | import colors from "../../colors"; 5 | import { multiSelectInput, hiddenInput } from "../../commonStyles"; 6 | 7 | interface CheckboxProps { 8 | index: number, 9 | option: string, 10 | handler: (event: ChangeEvent) => void 11 | } 12 | 13 | const generalStyles = css` 14 | cursor: pointer; 15 | 16 | label { 17 | width: 1em; 18 | height: 1em; 19 | top: 0.3rem; 20 | 21 | border-radius: 25%; 22 | cursor: pointer; 23 | } 24 | 25 | .unselected { 26 | background-color: white; 27 | } 28 | 29 | .unselected:focus-within, :hover .unselected { 30 | background-color: lightgray; 31 | } 32 | 33 | .checkmark { 34 | position: absolute; 35 | } 36 | `; 37 | 38 | const activeStyles = css` 39 | .selected { 40 | background-color: ${colors.blurple}; 41 | } 42 | 43 | .selected .checkmark { 44 | width: 0.30rem; 45 | height: 0.60rem; 46 | left: 0.25em; 47 | 48 | border: solid white; 49 | border-width: 0 0.2rem 0.2rem 0; 50 | 51 | transform: rotate(45deg); 52 | } 53 | `; 54 | 55 | export default function Checkbox(props: CheckboxProps): JSX.Element { 56 | return ( 57 | 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/components/InputTypes/Code.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx, css } from "@emotion/react"; 3 | import React from "react"; 4 | 5 | import CodeMirror from "@uiw/react-codemirror"; 6 | import { python } from "@codemirror/lang-python"; 7 | import { atomone } from "@uiw/codemirror-theme-atomone"; 8 | 9 | import { selectable } from "../../commonStyles"; 10 | 11 | interface CodeProps { 12 | handler: (newContent: string) => void, 13 | questionId: string, 14 | } 15 | 16 | const styles = css` 17 | border: 3px solid lightgray; 18 | border-radius: 5px; 19 | overflow:auto; 20 | height: 20rem; 21 | 22 | .cm-editor { 23 | height: 100%; 24 | } 25 | `; 26 | 27 | export default function Code(props: CodeProps): JSX.Element { 28 | const onChange = React.useCallback((val: string) => { 29 | props.handler(val); 30 | }, []); 31 | 32 | return ; 33 | } 34 | -------------------------------------------------------------------------------- /src/components/InputTypes/Radio.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx, css } from "@emotion/react"; 3 | import { ChangeEvent } from "react"; 4 | import colors from "../../colors"; 5 | 6 | interface RadioProps { 7 | option: string, 8 | question_id: string, 9 | handler: (event: ChangeEvent) => void, 10 | onBlurHandler: () => void, 11 | index: number, 12 | } 13 | 14 | const containerStyles = css` 15 | position: relative; 16 | margin-bottom: 10px; 17 | `; 18 | 19 | const inputStyles = css` 20 | position: absolute; 21 | opacity: 0; 22 | &:checked + label { 23 | background-color: ${colors.success}; 24 | } 25 | `; 26 | 27 | const labelStyles = css` 28 | display: flex; 29 | align-items: center; 30 | background-color: ${colors.darkerGreyple}; 31 | padding: 10px; 32 | border-radius: 5px; 33 | cursor: pointer; 34 | transition: background-color 0.25s ease, transform 0.25s ease; 35 | transform: none; 36 | 37 | :hover { 38 | background-color: ${colors.greyple}; 39 | transform: translateX(5px); 40 | } 41 | `; 42 | 43 | export default function Radio(props: RadioProps): JSX.Element { 44 | const calculatedID = `${props.question_id}-${props.index}`; 45 | 46 | return ( 47 |
48 | 56 | 59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/components/InputTypes/Range.tsx: -------------------------------------------------------------------------------- 1 | /** @jsx jsx */ 2 | import { jsx, css } from "@emotion/react"; 3 | import React, { ChangeEvent } from "react"; 4 | import colors from "../../colors"; 5 | import { hiddenInput, multiSelectInput } from "../../commonStyles"; 6 | 7 | interface RangeProps { 8 | question_id: string, 9 | options: Array, 10 | handler: (event: ChangeEvent) => void, 11 | required: boolean, 12 | onBlurHandler: () => void 13 | } 14 | 15 | const containerStyles = css` 16 | display: flex; 17 | justify-content: space-between; 18 | position: relative; 19 | width: 100%; 20 | 21 | @media (max-width: 800px) { 22 | width: 20%; 23 | display: block; 24 | margin: 0 auto; 25 | 26 | label span { 27 | margin-left: 0; 28 | transform: translateY(1.6rem) translateX(2rem); 29 | } 30 | } 31 | `; 32 | 33 | const optionStyles = css` 34 | display: inline-block; 35 | transform: translateX(-50%); 36 | margin: 0 50%; 37 | 38 | white-space: nowrap; 39 | 40 | transition: transform 300ms; 41 | `; 42 | 43 | const selectorStyles = css` 44 | cursor: pointer; 45 | 46 | div { 47 | width: 1rem; 48 | height: 1rem; 49 | 50 | background-color: whitesmoke; 51 | 52 | border-radius: 50%; 53 | margin: 0 100% 0 0; 54 | } 55 | 56 | :hover div, :focus-within div { 57 | background-color: lightgray; 58 | } 59 | 60 | input:checked+div { 61 | background-color: ${colors.blurple}; 62 | } 63 | `; 64 | 65 | const sliderContainerStyles = css` 66 | display: flex; 67 | justify-content: center; 68 | width: 100%; 69 | 70 | position: absolute; 71 | z-index: -1; 72 | 73 | top: 2.1rem; 74 | 75 | transition: all 300ms; 76 | 77 | @media (max-width: 800px) { 78 | width: 0.5rem; 79 | height: 80%; 80 | 81 | left: 0.4rem; 82 | 83 | background: whitesmoke; 84 | } 85 | `; 86 | 87 | const sliderStyles = css` 88 | width: 98%; /* Needs to be slightly smaller than container to work on all devices */ 89 | height: 0.5rem; 90 | background-color: whitesmoke; 91 | 92 | transition: transform 300ms; 93 | 94 | @media (max-width: 800px) { 95 | display: none; 96 | } 97 | `; 98 | 99 | export default function Range(props: RangeProps): JSX.Element { 100 | const range = props.options.map((option, index) => { 101 | return ( 102 |