diff --git a/.dockerignore b/.dockerignore index 7d6f6c59..66c17e07 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,4 +16,7 @@ /pangraph.tar.gz /vendor /playgrounds -/script \ No newline at end of file +/script +\.build/ +\.cache/ +node_modules/ diff --git a/.gitignore b/.gitignore index 9c6645f5..0c553f3f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,33 +1,33 @@ \.cache -.dep -.local -data -viewer -figs -log -docs/build -docs/repo -notes/ -vendor -pangraph -pangraph.tar.gz -bin -tutorial -playgrounds -.vscode -script/synthetic_data -script/panx_data -script/.snakemake -script/projections -script/size-benchmark -script/incremental_size -script/panx_results -script/local_scripts +/\.dep +/\.local +/data +/viewer +/figs +/log +/docs/build +/docs/repo +/notes/ +/vendor +/pangraph +/pangraph.tar.gz +/bin +/tutorial +/playgrounds +/\.vscode +/script/synthetic_data +/script/panx_data +/script/.snakemake +/script/projections +/script/size-benchmark +/script/incremental_size +/script/panx_results +/script/local_scripts __pycache__ -tests/data +/tests/data -deps/minimap2/build -deps/minimap2/products +/deps/minimap2/build +/deps/minimap2/products *.aux *.bbl diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..72e4a483 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +18.14.2 diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 00000000..60317849 --- /dev/null +++ b/.vercelignore @@ -0,0 +1,21 @@ +/.cache +/.dep +/.env +/.github +/.idea +/.local +/.vscode* +/Dockerfile +/data +/deps/minimap2/build +/deps/minimap2/products +/docs +/example_datasets +/pangraph +/pangraph.tar.gz +/vendor +/playgrounds +/script +\.build/ +\.cache/ +node_modules/ diff --git a/docker-dev b/docker-dev new file mode 100755 index 00000000..2007493f --- /dev/null +++ b/docker-dev @@ -0,0 +1,41 @@ +#!/usr/bin/env bash + +set -euo pipefail + +export NICE="nice -14 ionice -c2 -n3" + +DOCKER_REPO="neherlab/pangraph" +DOCKER_IMAGE_NAME_SAFE="${DOCKER_REPO//\//-}" +DOCKER_CONTAINER_NAME="${DOCKER_IMAGE_NAME_SAFE}-$(date +%s)" + +USER="user" +GROUP="group" + +DEFAULT_COMMAND="cd web && yarn install && yarn dev" + +${NICE} docker build -q \ + --file="docker/docker-dev.dockerfile" \ + --tag="${DOCKER_REPO}" \ + --network=host \ + --build-arg="UID=$(id -u)" \ + --build-arg="GID=$(id -g)" \ + --build-arg="USER=${USER}" \ + --build-arg="GROUP=${GROUP}" \ + "$(pwd)" >/dev/null + +${NICE} docker run -it --rm \ + --network=host \ + --init \ + --name="${DOCKER_CONTAINER_NAME}" \ + --hostname="${DOCKER_IMAGE_NAME_SAFE}" \ + --user="$(id -u):$(id -g)" \ + --volume="$(pwd):/workdir" \ + --workdir="/workdir" \ + --env="UID=$(id -u)" \ + --env="GID=$(id -g)" \ + --env="USER=${USER}" \ + --env="GROUP=${GROUP}" \ + --env="PS1=\${USER}@\${HOST}" \ + --ulimit core=0 \ + "${DOCKER_REPO}" \ + bash -c "set -euo pipefail; ${*:-${DEFAULT_COMMAND}}" diff --git a/docker/docker-dev.dockerfile b/docker/docker-dev.dockerfile new file mode 100644 index 00000000..2e9e28a2 --- /dev/null +++ b/docker/docker-dev.dockerfile @@ -0,0 +1,91 @@ +# Freeze base image version to +# ubuntu:20.04 (pushed 2023-02-01T17:52:39.31842Z) +# https://hub.docker.com/layers/ubuntu/library/ubuntu/20.04/images/sha256-bffb6799d706144f263f4b91e1226745ffb5643ea0ea89c2f709208e8d70c999 +FROM ubuntu@sha256:bffb6799d706144f263f4b91e1226745ffb5643ea0ea89c2f709208e8d70c999 + +SHELL ["bash", "-euxo", "pipefail", "-c"] + +ARG NODEMON_VERSION="2.0.15" +ARG YARN_VERSION="1.22.18" + +RUN set -euxo pipefail >/dev/null \ +&& export DEBIAN_FRONTEND=noninteractive \ +&& apt-get update -qq --yes \ +&& apt-get install -qq --no-install-recommends --yes \ + bash \ + bash-completion \ + build-essential \ + ca-certificates \ + curl \ + git \ + gnupg \ + python3 \ + python3-pip \ + python3-setuptools \ + python3-wheel \ + sudo \ + time \ +>/dev/null \ +&& apt-get clean autoclean >/dev/null \ +&& apt-get autoremove --yes >/dev/null \ +&& rm -rf /var/lib/apt/lists/* + +ARG USER=user +ARG GROUP=user +ARG UID +ARG GID + +ENV USER=$USER +ENV GROUP=$GROUP +ENV UID=$UID +ENV GID=$GID +ENV TERM="xterm-256color" +ENV HOME="/home/${USER}" +ENV NODE_DIR="/opt/node" +ENV PATH="${NODE_DIR}/bin:${HOME}/.local/bin:${PATH}" + + +# Install Node.js +COPY .nvmrc / +RUN set -eux >dev/null \ +&& mkdir -p "${NODE_DIR}" \ +&& cd "${NODE_DIR}" \ +&& NODE_VERSION=$(cat /.nvmrc) \ +&& curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-x64.tar.xz" | tar -xJ --strip-components=1 \ +&& npm install -g nodemon@${NODEMON_VERSION} yarn@${YARN_VERSION} >/dev/null + + +# Calm down the (in)famous chatter from yarn +RUN set -euxo pipefail >/dev/null \ +&& sed -i'' "s/this.reporter.warn(this.reporter.lang('incompatibleResolutionVersion', pattern, reqPattern));//g" "${NODE_DIR}/lib/node_modules/yarn/lib/cli.js" \ +&& sed -i'' "s/_this2\.reporter.warn(_this2\.reporter.lang('ignoredScripts'));//g" "${NODE_DIR}/lib/node_modules/yarn/lib/cli.js" \ +&& sed -i'' 's/_this3\.reporter\.warn(_this3\.reporter\.lang(peerError.*;//g' "/opt/node/lib/node_modules/yarn/lib/cli.js" + + +# Make a user and group +RUN set -euxo pipefail >/dev/null \ +&& \ + if [ -z "$(getent group ${GID})" ]; then \ + addgroup --system --gid ${GID} ${GROUP}; \ + else \ + groupmod -n ${GROUP} $(getent group ${GID} | cut -d: -f1); \ + fi \ +&& \ + if [ -z "$(getent passwd ${UID})" ]; then \ + useradd \ + --system \ + --create-home --home-dir ${HOME} \ + --shell /bin/bash \ + --gid ${GROUP} \ + --groups sudo \ + --uid ${UID} \ + ${USER}; \ + fi \ +&& sed -i /etc/sudoers -re 's/^%sudo.*/%sudo ALL=(ALL:ALL) NOPASSWD: ALL/g' \ +&& sed -i /etc/sudoers -re 's/^root.*/root ALL=(ALL:ALL) NOPASSWD: ALL/g' \ +&& sed -i /etc/sudoers -re 's/^#includedir.*/## **Removed the include directive** ##"/g' \ +&& echo "foo ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers \ +&& touch ${HOME}/.hushlogin \ +&& chown -R ${UID}:${GID} "${HOME}" + +USER ${USER} diff --git a/docs/src/dev/building-web.md b/docs/src/dev/building-web.md new file mode 100644 index 00000000..3b94b055 --- /dev/null +++ b/docs/src/dev/building-web.md @@ -0,0 +1,74 @@ +# Building Pangraph web app + +## Install dependencies + +Install Node.js version 18+ (the latest LTS release is recommended), by either downloading it from the official website: https://nodejs.org/en/download/, or by using [nvm](https://github.com/nvm-sh/nvm). We don't recommend using Node.js from the package manager of your operating system, and neither from conda nor other sources. Make sure the `node`, `npm` and `yarn` executables are in your `$PATH`: + +```bash +node --version +npm --version +yarn --version +``` + +## Build and run in development mode + +Development mode is suitable only for local day-to-day development. It is relatively fast to build and rebuild (incrementally), slow to run, contains lots of debug info. It looks and feels mostly like the final app the users will see, but the way it is implemented is very different. So dev mode is only suitable for day-to-day dev tasks. All testing must be performed on production app. + +In order to build and run the app in dev mode. Run this sequence of commands: + +```bash +cd web + +# Prepare environment variables with default values +cp .env.example .env + +# Install dependency packages +yarn install + +# Run development server +yarn dev +``` + +Open http://localhost:3000 in the browser. + +The build is lazy, so page code is only compiled when you make a request that page in the browser. Code modifications should trigger incremental rebuild and fast refresh in the browser. + + +## Build and run in production mode + +Production version of the app closely corresponds to what will be shipped to end users. The build is always a full build and the result of it are the HTML, CSS and JS files which can be served using any static web server. + +In order to build and run the app in production mode (fast to build, slow to run, lots of debug info) Run this sequence of commands: + +```bash +cd web + +# Prepare environment variables with default values +cp .env.example .env + +# Install dependency packages +yarn install + +# Build production app files +yarn prod:build + +# Serve production app files locally +yarn prod:serve + +# There is also a shortcut that runs prod:build && prod:serve: +# yarn prod +``` + +Open a http://localhost:8080 in the browser. + +The production version has no fast refresh and it always performs the full rebuild. + + +## Deployment + +TODO + + +## Feedback + +If something does not work, or if you have ideas on improving the build setup, feel free to open an issue or a pull request. diff --git a/vercel.json b/vercel.json new file mode 100644 index 00000000..1323cdac --- /dev/null +++ b/vercel.json @@ -0,0 +1,8 @@ +{ + "rewrites": [ + { + "source": "/(.*)", + "destination": "/index.html" + } + ] +} diff --git a/web/.browserslistrc b/web/.browserslistrc new file mode 100644 index 00000000..347e11d3 --- /dev/null +++ b/web/.browserslistrc @@ -0,0 +1,10 @@ +[production] +defaults +cover 99.5% +since 2015 +ie >= 10 +safari >= 10 + +[development] +last 5 Chrome versions +last 5 Firefox versions diff --git a/web/.editorconfig b/web/.editorconfig new file mode 100644 index 00000000..eb3edb17 --- /dev/null +++ b/web/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +max_line_length = 120 +trim_trailing_whitespace = true + +[{*.txt,*.rst,*.md,*.mdx,*.tex}] +max_line_length = 9999999 +trim_trailing_whitespace = false + +[{Makefile,*.makefile,go.mod,go.sum,*.go}] +indent_style = tab diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 00000000..2b9ca3c9 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,20 @@ +FULL_DOMAIN=autodetect +DATA_ROOT_URL=/data + +WEB_PORT_DEV=3000 +WEB_PORT_PROD=8080 +WEB_PORT_ANALYZE=8888 + +NEXT_TELEMETRY_DISABLED=0 +RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false + +DEV_ENABLE_TYPE_CHECKS=1 +DEV_ENABLE_ESLINT=1 + +PROD_ENABLE_SOURCE_MAPS=1 +PROD_ENABLE_TYPE_CHECKS=1 +PROD_ENABLE_ESLINT=1 + +PROFILE=0 + +WATCH_POLL=0 diff --git a/web/.env.vercel b/web/.env.vercel new file mode 100644 index 00000000..79584de3 --- /dev/null +++ b/web/.env.vercel @@ -0,0 +1,20 @@ +FULL_DOMAIN=autodetect +DATA_ROOT_URL=/data + +WEB_PORT_DEV=3000 +WEB_PORT_PROD=null +WEB_PORT_ANALYZE=8888 + +NEXT_TELEMETRY_DISABLED=0 +RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false + +DEV_ENABLE_TYPE_CHECKS=1 +DEV_ENABLE_ESLINT=1 + +PROD_ENABLE_SOURCE_MAPS=1 +PROD_ENABLE_TYPE_CHECKS=0 +PROD_ENABLE_ESLINT=0 + +PROFILE=0 + +WATCH_POLL=0 diff --git a/web/.eslintignore b/web/.eslintignore new file mode 100644 index 00000000..9e49c9b5 --- /dev/null +++ b/web/.eslintignore @@ -0,0 +1,17 @@ +3rdparty +\.build +\.cache +\.env +\.eslintrc.js +\.github +\.idea +\.ignore +\.reports +\.vscode +config/next/lib/EmitFilePlugin.js +infra/lambda-at-edge/basicAuth.js +node_modules +public +src/vendor +styles +tsconfig.json diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 00000000..67afd63d --- /dev/null +++ b/web/.eslintrc.cjs @@ -0,0 +1,264 @@ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest', + ecmaFeatures: { + jsx: true, + globalReturn: false, + }, + project: './tsconfig.eslint.json', + warnOnUnsupportedTypeScriptVersion: true, + }, + globals: {}, + extends: [ + 'eslint:recommended', + + 'airbnb', + 'airbnb-typescript', + 'airbnb/hooks', + 'react-app', + + 'next/core-web-vitals', + 'plugin:@typescript-eslint/eslint-recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:@typescript-eslint/recommended-requiring-type-checking', + 'plugin:array-func/all', + 'plugin:import/errors', + 'plugin:import/typescript', + 'plugin:import/warnings', + 'plugin:jest/recommended', + 'plugin:jest/style', + 'plugin:jsx-a11y/recommended', + 'plugin:lodash/recommended', + 'plugin:promise/recommended', + 'plugin:react/recommended', + 'plugin:react-perf/all', + 'plugin:security/recommended', + 'plugin:sonarjs/recommended', + 'plugin:unicorn/recommended', + + 'plugin:prettier/recommended', + ], + plugins: [ + 'array-func', + 'cflint', + 'import', + 'jest', + 'jsx-a11y', + 'lodash', + 'no-loops', + 'no-secrets', + 'node', + 'only-ascii', + 'promise', + 'react', + 'react-hooks', + 'react-perf', + 'security', + 'sonarjs', + 'unicorn', + + 'only-warn', + + '@typescript-eslint', + + 'prettier', + ], + reportUnusedDisableDirectives: true, + rules: { + '@next/next/no-img-element': 'off', + '@next/next/no-title-in-document-head': 'off', + '@typescript-eslint/array-type': 'off', + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/lines-between-class-members': 'off', + '@typescript-eslint/naming-convention': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-shadow': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/restrict-template-expressions': 'off', + '@typescript-eslint/unbound-method': ['off'], + 'array-func/no-unnecessary-this-arg': 'off', + 'array-func/prefer-array-from': 'off', + 'camelcase': 'off', + 'cflint/no-substr': 'warn', + 'cflint/no-this-assignment': 'warn', + 'import/extensions': [ + 'warn', + 'ignorePackages', + { + js: 'never', + jsx: 'never', + mjs: 'never', + ts: 'never', + tsx: 'never', + }, + ], + 'import/no-extraneous-dependencies': ['warn', { devDependencies: true }], + 'import/no-unresolved': 'off', + 'import/no-webpack-loader-syntax': 'off', + 'import/order': 'warn', + 'import/prefer-default-export': 'off', + 'jest/consistent-test-it': 'warn', + 'jest/expect-expect': 'warn', + 'jest/no-done-callback': 'warn', + 'jsx-a11y/label-has-associated-control': ['warn', { assert: 'either' }], + 'lodash/chaining': 'off', + 'lodash/import-scope': 'off', + 'lodash/prefer-constant': 'off', + 'lodash/prefer-lodash-chain': 'off', + 'lodash/prefer-lodash-method': 'off', + 'lodash/prefer-lodash-typecheck': 'off', + 'lodash/prefer-noop': 'off', + 'lodash/prop-shorthand': 'off', + 'max-classes-per-file': 'off', + 'no-console': ['warn', { allow: ['info', 'warn', 'error'] }], + 'no-loops/no-loops': 'warn', + 'no-param-reassign': ['warn', { ignorePropertyModificationsFor: ['draft'] }], + 'no-secrets/no-secrets': ['warn', { tolerance: 5 }], + 'no-shadow': 'off', + 'only-ascii/only-ascii': 'warn', + 'prefer-for-of': 'off', + 'prettier/prettier': 'warn', + 'react/jsx-curly-brace-presence': 'off', + 'react/jsx-filename-extension': ['warn', { extensions: ['.js', '.jsx', '.ts', '.tsx'] }], + 'react/jsx-props-no-spreading': 'off', + 'react/no-unused-prop-types': 'off', + 'react/prop-types': 'off', + 'react/react-in-jsx-scope': 'off', + 'react/require-default-props': 'off', + 'react/state-in-constructor': 'off', + 'security/detect-non-literal-fs-filename': 'off', + 'security/detect-object-injection': 'off', + 'sonarjs/cognitive-complexity': ['warn', 20], + 'unicorn/escape-case': 'off', + 'unicorn/filename-case': 'off', + 'unicorn/import-style': 'off', + 'unicorn/new-for-builtins': 'off', + 'unicorn/no-abusive-eslint-disable': 'warn', + 'unicorn/no-array-callback-reference': 'off', + 'unicorn/no-array-for-each': 'off', + 'unicorn/no-array-method-this-argument': 'off', + 'unicorn/no-array-reduce': 'off', + 'unicorn/no-fn-reference-in-iterator': 'off', + 'unicorn/no-negated-condition': 'off', + 'unicorn/no-null': 'off', + 'unicorn/no-reduce': 'off', + 'unicorn/no-typeof-undefined': 'off', + 'unicorn/no-useless-undefined': 'off', + 'unicorn/no-zero-fractions': 'off', + 'unicorn/prefer-node-protocol': 'off', + 'unicorn/prefer-query-selector': 'off', + 'unicorn/prefer-spread': 'off', + 'unicorn/prevent-abbreviations': 'off', + + 'lines-between-class-members': ['warn', 'always', { exceptAfterSingleLine: true }], + + 'require-await': 'off', + '@typescript-eslint/require-await': 'off', + + 'no-unused-expressions': 'off', + '@typescript-eslint/no-unused-expressions': 'warn', + + '@typescript-eslint/no-duplicate-imports': 'off', + }, + env: { + browser: true, + es6: true, + jest: true, + node: true, + }, + settings: { + 'react': { + version: 'detect', + }, + 'import/parsers': { + '@typescript-eslint/parser': ['.js', '.jsx', '.ts', '.tsx'], + }, + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + }, + }, + }, + overrides: [ + { + files: ['src/pages/**/*', 'src/types/**/*'], + rules: { + 'no-restricted-exports': 'off', + }, + }, + { + files: ['*.d.ts'], + rules: { + '@typescript-eslint/ban-types': ['warn', { extendDefaults: true, types: { object: false } }], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'off', + 'import/no-duplicates': 'off', + 'no-useless-constructor': 'off', + 'react/prefer-stateless-function': 'off', + }, + }, + { + files: [ + '.eslintrc.js', + 'babel-node.config.js', + 'config/**/*.js', + 'config/**/*.ts', + 'config/jest/mocks/**/*.js', + 'infra/**/*.js', + 'jest-runner-eslint.config.js', + 'jest.config.js', + 'lib/EnvVarError.js', + 'lib/findModuleRoot.js', + 'lib/getenv.js', + 'next.config.mjs', + 'postcss.config.cjs', + 'tools/**/*.js', + 'tools/**/*.ts', + 'webpack.config.js', + ], + rules: { + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/restrict-template-expressions': 'off', + 'global-require': 'off', + 'import/extensions': 'off', + 'import/no-anonymous-default-export': 'off', + 'import/no-import-module-exports': 'off', + 'security/detect-child-process': 'off', + 'sonarjs/cognitive-complexity': ['warn', 50], + 'unicorn/prefer-module': 'off', + }, + }, + { + files: ['config/jest/mocks/**/*.js'], + rules: { + 'no-constructor-return': 'off', + 'react/display-name': 'off', + }, + }, + { + files: ['**/*.test.*', '**/__test__/**', '**/__tests__/**', '**/test/**', '**/tests/**'], + rules: { + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/restrict-template-expressions': 'off', + 'sonarjs/no-duplicate-string': 'off', + 'sonarjs/no-identical-functions': 'off', + }, + }, + ], +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 00000000..6a2f200b --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,12 @@ +*.log +.DS_Store +\.build +\.cache +\.env +\.idea +\.ignore +\.json-autotranslate-cache/ +\.reports +\.vscode +dist/ +node_modules diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 00000000..4315e0f6 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,18 @@ +*.lock +*.log +\.build +\.cache +\.editorconfig +\.env +\.git +\.gitignore +\.idea +\.ignore +\.prettierignore +\.reports +\.vscode +node_modules +src/assets/data +tools/augur_profile +/data +/public diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 00000000..bc719f53 --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,9 @@ +{ + "arrowParens": "always", + "printWidth": 120, + "proseWrap": "always", + "quoteProps": "consistent", + "semi": false, + "singleQuote": true, + "trailingComma": "all" +} diff --git a/web/.yarnrc b/web/.yarnrc new file mode 100644 index 00000000..2a99e5de --- /dev/null +++ b/web/.yarnrc @@ -0,0 +1,16 @@ +# Don't check for updates of yarn itself +disable-self-update-check true + +# Install exact versions (not prefixed with '^') +save-prefix "" + +# Don't run post-install scripts (for security reasons) +--install.ignore-scripts true +--add.ignore-scripts true +--remove.ignore-scripts true + +# Don't check Node version +ignore-engines true + +# Keep yarn cache locally for faster and more reproducible builds +cache-folder ".cache/yarn" diff --git a/web/config/dotenv/index.js b/web/config/dotenv/index.js new file mode 100644 index 00000000..23862785 --- /dev/null +++ b/web/config/dotenv/index.js @@ -0,0 +1,6 @@ +import { join } from 'path' +import { config } from 'dotenv' +import { findModuleRoot } from '../../lib/findModuleRoot' + +const { moduleRoot } = findModuleRoot() +config({ path: join(moduleRoot, '.env') }) diff --git a/web/config/i18next/i18next.config.cjs b/web/config/i18next/i18next.config.cjs new file mode 100644 index 00000000..b6fd483f --- /dev/null +++ b/web/config/i18next/i18next.config.cjs @@ -0,0 +1,66 @@ +module.exports = { + contextSeparator: '_', + // Key separator used in your translation keys + + createOldCatalogs: false, + // Save the \_old files + + defaultNamespace: 'translation', + // Default namespace used in your i18next config + + // defaultValue: '', + // Default value to give to empty keys + + indentation: 2, + // Indentation of the catalog files + + keepRemoved: false, + // Keep keys from the catalog that are no longer in code + + keySeparator: false, + // Key separator used in your translation keys + // If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance. + + // see below for more details + lexers: { + js: ['JavascriptLexer'], // if you're writing jsx inside .js files, change this to JsxLexer + ts: ['JavascriptLexer'], + jsx: ['JsxLexer'], + tsx: ['JsxLexer'], + + default: ['JavascriptLexer'], + }, + + lineEnding: '\n', + // Control the line ending. See options at https://github.com/ryanve/eol + + locales: ['en'], + // An array of the locales in your applications + + namespaceSeparator: false, + // Namespace separator used in your translation keys + // If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance. + + output: 'src/i18n/resources/$LOCALE/common.json', + // Supports $LOCALE and $NAMESPACE injection + // Supports JSON (.json) and YAML (.yml) file formats + // Where to write the locale files relative to process.cwd() + + input: ['../../src/**/*.{ts,tsx,js,jsx}', '!../../src/i18n/**'], + // An array of globs that describe where to look for source files + // relative to the location of the configuration file + + reactNamespace: false, + // For react file, extract the defaultNamespace - https://react.i18next.com/components/translate-hoc.html + // Ignored when parsing a `.jsx` file and namespace is extracted from that file. + + sort: true, + // Whether or not to sort the catalog + + useKeysAsDefaultValue: true, + // Whether to use the keys as the default value; ex. "Hello": "Hello", "World": "World" + // The option `defaultValue` will not work if this is set to true + + verbose: false, + // Display info about the parsing including some stats +} diff --git a/web/config/jest/jest.config.js b/web/config/jest/jest.config.js new file mode 100644 index 00000000..768d36ea --- /dev/null +++ b/web/config/jest/jest.config.js @@ -0,0 +1,35 @@ +require('../dotenv') + +const path = require('path') + +const { findModuleRoot } = require('../../lib/findModuleRoot') + +const { moduleRoot } = findModuleRoot() + +const shouldRunEslint = process.env.WITH_ESLINT === '1' + +module.exports = { + rootDir: moduleRoot, + + projects: [require('./jest.tests.config.js'), shouldRunEslint && require('./jest.eslint.config.js')].filter(Boolean), + + coverageDirectory: path.join(moduleRoot, '.reports', 'coverage'), + + collectCoverageFrom: [ + '!/**/*.d.ts', + '!/**/node_modules/**/*', + '/src/**/*.{js,jsx,ts,tsx}', + '!/src/index.polyfilled.{js,jsx,ts,tsx}', + '!/src/locales/**/*', + ], + + coverageThreshold: { + global: { + // TODO: write more tests? + // branches: 33, + // functions: 33, + // lines: 33, + // statements: 33, + }, + }, +} diff --git a/web/config/jest/jest.eslint.config.js b/web/config/jest/jest.eslint.config.js new file mode 100644 index 00000000..1bacd879 --- /dev/null +++ b/web/config/jest/jest.eslint.config.js @@ -0,0 +1,33 @@ +require('../dotenv') + +const { findModuleRoot } = require('../../lib/findModuleRoot') + +const { moduleRoot } = findModuleRoot() + +module.exports = { + rootDir: moduleRoot, + roots: ['/src'], + runner: 'jest-runner-eslint', + displayName: { name: 'lint', color: 'blue' }, + testEnvironment: 'jest-environment-jsdom', + preset: 'ts-jest', + globals: { + 'ts-jest': { + babelConfig: true, + diagnostics: { + pathRegex: /(\/__tests?__\/.*|([./])(test|spec))\.[jt]sx?$/, + warnOnly: true, + }, + }, + }, + testMatch: [ + '/src/**/*.(spec|test).{js,jsx,ts,tsx}', + '/src/**/__test__/**/*.{js,jsx,ts,tsx}', + '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', + '/src/**/test/**/*.{js,jsx,ts,tsx}', + '/src/**/tests/**/*.{js,jsx,ts,tsx}', + ], + watchPlugins: [ + 'jest-runner-eslint/watch-fix', // prettier-ignore + ], +} diff --git a/web/config/jest/jest.tests.config.js b/web/config/jest/jest.tests.config.js new file mode 100644 index 00000000..04ceed53 --- /dev/null +++ b/web/config/jest/jest.tests.config.js @@ -0,0 +1,53 @@ +require('../dotenv') + +const { findModuleRoot } = require('../../lib/findModuleRoot') + +const { moduleRoot } = findModuleRoot() + +module.exports = { + rootDir: moduleRoot, + roots: ['/src'], + displayName: { name: 'test', color: 'cyan' }, + testEnvironment: 'jest-environment-jsdom', + preset: 'ts-jest', + globals: { + 'ts-jest': { + babelConfig: true, + diagnostics: { + pathRegex: /(\/__tests?__\/.*|([./])(test|spec))\.[jt]sx?$/, + warnOnly: true, + }, + }, + }, + transform: { + '^.+\\.[t|j]sx?$': 'ts-jest', + '^.+\\.(md|mdx)$': 'jest-transformer-mdx', + '\\.(txt|fasta|csv|tsv)': 'jest-raw-loader', + }, + testMatch: [ + '/src/**/*.(spec|test).{js,jsx,ts,tsx}', + '/src/**/__test__/**/*.{js,jsx,ts,tsx}', + '/src/**/__tests__/**/*.{js,jsx,ts,tsx}', + '/src/**/test/**/*.{js,jsx,ts,tsx}', + '/src/**/tests/**/*.{js,jsx,ts,tsx}', + ], + transformIgnorePatterns: ['node_modules/(?!(d3-scale)/)'], + moduleNameMapper: { + '^src/(.*)': '/src/$1', + '\\.(eot|otf|webp|ttf|woff\\d?|svg|png|jpe?g|gif)$': '/config/jest/mocks/fileMock.js', + '\\.(css|scss)$': 'identity-obj-proxy', + 'react-children-utilities': '/config/jest/mocks/mockReactChildrenUtilities.js', + 'react-i18next': '/config/jest/mocks/mockReactI18next.js', + 'popper-js': '/config/jest/mockPopperJS.js', + 'use-debounce': '/config/jest/mocks/mockUseDebounce.js', + }, + setupFiles: ['core-js', 'regenerator-runtime'], + setupFilesAfterEnv: [ + '/config/jest/setupDotenv.js', + 'jest-chain', + 'jest-extended', + 'jest-axe/extend-expect', + '@testing-library/jest-dom/extend-expect', + ], + watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], +} diff --git a/web/config/jest/mocks/fileMock.js b/web/config/jest/mocks/fileMock.js new file mode 100644 index 00000000..7e27f6ad --- /dev/null +++ b/web/config/jest/mocks/fileMock.js @@ -0,0 +1 @@ +module.exports = __filename diff --git a/web/config/jest/mocks/mockPopperJS.js b/web/config/jest/mocks/mockPopperJS.js new file mode 100644 index 00000000..5775760e --- /dev/null +++ b/web/config/jest/mocks/mockPopperJS.js @@ -0,0 +1,11 @@ +// mock popper.js (reactstrap dependency via react-popper) +// see: https://github.com/popperjs/popper-core/issues/478#issuecomment-341494703 + +export default class { + constructor() { + return { + destroy: () => null, + scheduleUpdate: () => null, + } + } +} diff --git a/web/config/jest/mocks/mockReactChildrenUtilities.js b/web/config/jest/mocks/mockReactChildrenUtilities.js new file mode 100644 index 00000000..45c02e40 --- /dev/null +++ b/web/config/jest/mocks/mockReactChildrenUtilities.js @@ -0,0 +1,3 @@ +module.exports = { + onlyText: (text) => text, +} diff --git a/web/config/jest/mocks/mockUseDebounce.js b/web/config/jest/mocks/mockUseDebounce.js new file mode 100644 index 00000000..dc200b53 --- /dev/null +++ b/web/config/jest/mocks/mockUseDebounce.js @@ -0,0 +1,3 @@ +export function useDebouncedCallback(f) { + return [f] +} diff --git a/web/config/jest/setupDotenv.js b/web/config/jest/setupDotenv.js new file mode 100644 index 00000000..76486cae --- /dev/null +++ b/web/config/jest/setupDotenv.js @@ -0,0 +1 @@ +require('../dotenv') diff --git a/web/config/next/lib/CustomWebpackConfig.ts b/web/config/next/lib/CustomWebpackConfig.ts new file mode 100644 index 00000000..b7a6c7a8 --- /dev/null +++ b/web/config/next/lib/CustomWebpackConfig.ts @@ -0,0 +1,9 @@ +import type { NextConfig } from 'next' +import type { WebpackConfigContext } from 'next/dist/server/config-shared' +import type { Configuration } from 'webpack' + +export type CustomWebpackConfig = ( + nextConfig: NextConfig, + webpackConfig: Configuration, + options: WebpackConfigContext, +) => Configuration diff --git a/web/config/next/lib/EmitFilePlugin.js b/web/config/next/lib/EmitFilePlugin.js new file mode 100644 index 00000000..9ea456f8 --- /dev/null +++ b/web/config/next/lib/EmitFilePlugin.js @@ -0,0 +1,141 @@ +// Taken from https://github.com/Kir-Antipov/emit-file-webpack-plugin/blob/a17e94f434c9185d659e18806d49f3c6bc73d706/index.js + +/** + * @author Kir_Antipov + * See LICENSE.md file in root directory for full license. + */ +import { Buffer } from 'buffer' +import path from 'path' +import webpack from 'webpack' + +const version = +webpack.version.split('.')[0] +// Webpack 5 exposes the sources property to ensure the right version of webpack-sources is used. +// require('webpack-sources') approach may result in the "Cannot find module 'webpack-sources'" error. +const { Source, RawSource } = webpack.sources || require('webpack-sources') + +/** + * @typedef {object} EmitFilePluginOptions + * + * @property {string} [path] + * OPTIONAL: defaults to the Webpack output path. + * Output path. + * Can be relative (to Webpack output path) or absolute. + * + * @property {string} filename + * REQUIRED. + * Name of the file to add to assets. + * + * @property {boolean} [hash] + * OPTIONAL: defaults to false. + * Adds the compilation hash to the filename. You can either choose within the filename + * where the hash is inserted by adding `[hash]` i.e. `test.[hash].js` or the hash will be + * appended to the end of the file i.e. `test.js?hash`. + * + * @property {number} [stage] + * OPTIONAL: defaults to the webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL. + * Asset processing stage. + * + * @property {string|Buffer|Source|((assets: Record) => (string|Buffer|Source))|((assets: Record) => (Promise))} content + * REQUIRED. + * File content. Can be either a string, a buffer, or a (asynchronous) function. + * If the resulting object is not a string or a buffer, it will be converted to JSON. + */ + +/** + * Webpack plugin to emit files. + * + * @param {EmitFilePluginOptions} options The EmitFilePlugin config. + */ +function EmitFilePlugin(options) { + if (!options) { + throw new Error(`${EmitFilePlugin.name}: Please provide 'options' for the ${EmitFilePlugin.name} config.`) + } + + if (!options.filename) { + throw new Error(`${EmitFilePlugin.name}: Please provide 'options.filename' in the ${EmitFilePlugin.name} config.`) + } + + if (!options.content && options.content !== '') { + throw new Error(`${EmitFilePlugin.name}: Please provide 'options.content' in the ${EmitFilePlugin.name} config.`) + } + + if (typeof options.stage == 'number' && version < 5) { + console.warn(`${EmitFilePlugin.name}: 'options.stage' is only available for Webpack version 5 and higher.`) + } + + this.options = options +} + +/** + * Plugin entry point. + * + * @param {webpack.Compiler} compiler The compiler. + */ +EmitFilePlugin.prototype.apply = function (compiler) { + if (version < 4) { + compiler.plugin('emit', (compilation, callback) => emitFile(this.options, compilation, callback, callback)) + } else if (version === 4) { + compiler.hooks.emit.tapAsync(EmitFilePlugin.name, (compilation, callback) => + emitFile(this.options, compilation, callback, callback), + ) + } else { + compiler.hooks.thisCompilation.tap(EmitFilePlugin.name, (compilation) => { + compilation.hooks.processAssets.tapPromise( + { + name: EmitFilePlugin.name, + stage: + typeof this.options.stage == 'number' + ? this.options.stage + : webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + }, + () => new Promise((resolve, reject) => emitFile(this.options, compilation, resolve, reject)), + ) + }) + } +} + +/** + * @param {EmitFilePluginOptions} options + * @param {webpack.Compilation} compilation + * @param {() => void} resolve + */ +function emitFile(options, compilation, resolve) { + const outputPath = options.path || compilation.options.output.path + + let filename = options.filename + + if (options.hash) { + const hash = compilation.hash || '' + if (filename.includes('[hash]')) { + filename = filename.replace('[hash]', hash) + } else if (hash) { + filename = `${filename}?${hash}` + } + } + + const outputPathAndFilename = path.resolve(compilation.options.output.path, outputPath, filename) + + const relativeOutputPath = path.relative(compilation.options.output.path, outputPathAndFilename) + + const contentOrPromise = typeof options.content == 'function' ? options.content(compilation.assets) : options.content + + const contentPromise = + contentOrPromise instanceof Promise ? contentOrPromise : new Promise((resolve) => resolve(contentOrPromise)) + + contentPromise.then((content) => { + const source = + content instanceof Source + ? content + : new RawSource(typeof content == 'string' || content instanceof Buffer ? content : JSON.stringify(content)) + + if (version < 5) { + compilation.assets[relativeOutputPath] = source + } else { + compilation.emitAsset(relativeOutputPath, source) + } + + resolve() + }) +} + +export default EmitFilePlugin diff --git a/web/config/next/lib/addWebpackConfig.ts b/web/config/next/lib/addWebpackConfig.ts new file mode 100644 index 00000000..2e5df5f8 --- /dev/null +++ b/web/config/next/lib/addWebpackConfig.ts @@ -0,0 +1,19 @@ +import type { NextConfig } from 'next' +import type { WebpackConfigContext } from 'next/dist/server/config-shared' +import type { Configuration } from 'webpack' + +import { CustomWebpackConfig } from './CustomWebpackConfig' + +export function addWebpackConfig(nextConfig: NextConfig, customWebpackConfig: CustomWebpackConfig) { + const webpack = (webpackConfig: Configuration, options: WebpackConfigContext) => { + const newConfig = customWebpackConfig(nextConfig, webpackConfig, options) + + if (typeof nextConfig.webpack === 'function') { + return nextConfig.webpack(newConfig, options) + } + + return newConfig + } + + return { ...nextConfig, webpack } +} diff --git a/web/config/next/lib/addWebpackLoader.ts b/web/config/next/lib/addWebpackLoader.ts new file mode 100644 index 00000000..d970d0ec --- /dev/null +++ b/web/config/next/lib/addWebpackLoader.ts @@ -0,0 +1,15 @@ +import type { NextConfig } from 'next' +import type { WebpackConfigContext } from 'next/dist/server/config-shared' +import type { Configuration, RuleSetRule } from 'webpack' + +import { addWebpackConfig } from './addWebpackConfig' + +export type GetLoaderFunction = (webpackConfig: Configuration, options: WebpackConfigContext) => RuleSetRule + +export function addWebpackLoader(nextConfig: NextConfig, getLoader: GetLoaderFunction) { + return addWebpackConfig(nextConfig, (nextConfig, webpackConfig, options) => { + const loader = getLoader(webpackConfig, options) + webpackConfig?.module?.rules?.push(loader) + return webpackConfig + }) +} diff --git a/web/config/next/lib/addWebpackPlugin.ts b/web/config/next/lib/addWebpackPlugin.ts new file mode 100644 index 00000000..6ea4bf36 --- /dev/null +++ b/web/config/next/lib/addWebpackPlugin.ts @@ -0,0 +1,25 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { NextConfig } from 'next' +import type { WebpackConfigContext } from 'next/dist/server/config-shared' +import type { Compiler, Configuration, WebpackPluginFunction, WebpackPluginInstance } from 'webpack' + +import { addWebpackConfig } from './addWebpackConfig' + +export function addWebpackPlugin( + nextConfig: NextConfig, + plugin: WebpackPluginInstance | WebpackPluginFunction | ((this: Compiler, compiler: Compiler) => void) | any, +) { + return addWebpackConfig( + nextConfig, + (nextConfig: NextConfig, webpackConfig: Configuration, { isServer }: WebpackConfigContext) => { + if (!isServer) { + if (webpackConfig?.plugins) { + webpackConfig.plugins.push(plugin) + } else { + return { plugins: [plugin] } + } + } + return webpackConfig + }, + ) +} diff --git a/web/config/next/lib/getEnvVars.ts b/web/config/next/lib/getEnvVars.ts new file mode 100644 index 00000000..3c87df5f --- /dev/null +++ b/web/config/next/lib/getEnvVars.ts @@ -0,0 +1,38 @@ +import { getbool, getenv } from '../../../lib/getenv' +import { getDomain } from '../../../lib/getDomain' + +export function getEnvVars() { + const NODE_ENV = getenv('NODE_ENV') + const PRODUCTION = NODE_ENV === 'production' + const PROFILE = getbool('PROFILE') + const DOMAIN = getDomain() + const DOMAIN_STRIPPED = DOMAIN.replace('https://', '').replace('http://', '') + const WATCH_POLL = getbool('WATCH_POLL', false) + const DATA_ROOT_URL = getenv('DATA_ROOT_URL') + + const common = { + NODE_ENV, + PRODUCTION, + PROFILE, + DOMAIN, + DOMAIN_STRIPPED, + WATCH_POLL, + DATA_ROOT_URL, + } + + if (PRODUCTION) { + return { + ...common, + ENABLE_SOURCE_MAPS: getbool('PROD_ENABLE_SOURCE_MAPS'), + ENABLE_ESLINT: getbool('PROD_ENABLE_ESLINT'), + ENABLE_TYPE_CHECKS: getbool('PROD_ENABLE_TYPE_CHECKS'), + } + } + + return { + ...common, + ENABLE_SOURCE_MAPS: true, + ENABLE_ESLINT: getbool('DEV_ENABLE_ESLINT'), + ENABLE_TYPE_CHECKS: getbool('DEV_ENABLE_TYPE_CHECKS'), + } +} diff --git a/web/config/next/loaders/removeDebugPackageLoader.cjs b/web/config/next/loaders/removeDebugPackageLoader.cjs new file mode 100644 index 00000000..787ce8fb --- /dev/null +++ b/web/config/next/loaders/removeDebugPackageLoader.cjs @@ -0,0 +1,22 @@ +function stripDebugRequire(source) { + return source + .replace( + /__importDefault\s*\(\s*require\s*\(\s*["']\s*debug\s*["']\s*\)\s*\)/g, + '{ default() { return function () {} } }\n', + ) + .replace( + /_interopRequireDefault\s*\(\s*require\s*\(\s*["']\s*debug\s*["']\s*\)\s*\)/g, + '{ default() { return function () {} } }\n', + ) + .replace(/.*?require\(\s*["']debug["']\s*\).*/g, '(function(){return function () {}})\n') + .replace(/import\s+(.+)\s+from\s+["']debug["']/g, 'const $1 = (function(){ return function () {} })\n') +} + +/** + * Webpack loader which removes imports and requires of the `debug` package from JavaScript. + * This is especially important in presence of WebWorkers: `debug` package uses `windows` global, which is not + * present in WebWorker environment. + */ +export default function removeDebugPackageLoader(source) { + this.callback(null, stripDebugRequire(source)) +} diff --git a/web/config/next/next.config.ts b/web/config/next/next.config.ts new file mode 100644 index 00000000..34cdd290 --- /dev/null +++ b/web/config/next/next.config.ts @@ -0,0 +1,183 @@ +import type { NextConfig } from 'next' +import path from 'path' +import { uniq } from 'lodash-es' +import getWithMDX from '@next/mdx' +import remarkBreaks from 'remark-breaks' +import remarkMath from 'remark-math' +import remarkToc from 'remark-toc' +import remarkSlug from 'remark-slug' +import remarkImages from 'remark-images' + +import { findModuleRoot } from '../../lib/findModuleRoot' +import { getGitBranch } from '../../lib/getGitBranch' +import { getBuildNumber } from '../../lib/getBuildNumber' +import { getBuildUrl } from '../../lib/getBuildUrl' +import { getGitCommitHash } from '../../lib/getGitCommitHash' +import { getEnvVars } from './lib/getEnvVars' + +import getWithExtraWatch from './withExtraWatch' +import getWithFriendlyConsole from './withFriendlyConsole' +import { getWithRobotsTxt } from './withRobotsTxt' +import getWithTypeChecking from './withTypeChecking' +import withoutDebugPackage from './withoutDebugPackage' +import withSvg from './withSvg' +import withIgnore from './withIgnore' +import withoutMinification from './withoutMinification' +import withFriendlyChunkNames from './withFriendlyChunkNames' +import withResolve from './withResolve' +import withWebpackWatchPoll from './withWebpackWatchPoll' +import withUrlAsset from './withUrlAsset' + +const { + PRODUCTION, + PROFILE, + ENABLE_SOURCE_MAPS, + ENABLE_ESLINT, + ENABLE_TYPE_CHECKS, + DOMAIN, + DOMAIN_STRIPPED, + WATCH_POLL, + DATA_ROOT_URL, +} = getEnvVars() + +const BRANCH_NAME = getGitBranch() + +const { pkg, moduleRoot } = findModuleRoot() + +const clientEnv = { + BRANCH_NAME, + PACKAGE_VERSION: pkg.version ?? '', + BUILD_NUMBER: getBuildNumber(), + TRAVIS_BUILD_WEB_URL: getBuildUrl(), + COMMIT_HASH: getGitCommitHash(), + DOMAIN, + DOMAIN_STRIPPED, + DATA_ROOT_URL, +} + +const transpilationListDev = [ + // prettier-ignore + 'd3-scale', +] + +const transpilationListProd = uniq([ + // prettier-ignore + ...transpilationListDev, + 'debug', + 'lodash', + 'react-share', + 'recharts', + 'semver', +]) + +const nextConfig: NextConfig = { + distDir: `.build/${process.env.NODE_ENV}/tmp`, + pageExtensions: ['js', 'jsx', 'ts', 'tsx', 'md', 'mdx', 'all-contributorsrc'], + onDemandEntries: { + maxInactiveAge: 180 * 1000, + pagesBufferLength: 5, + }, + reactStrictMode: true, + cleanDistDir: true, + experimental: { + legacyBrowsers: true, + newNextLinkBehavior: true, + scrollRestoration: true, + swcMinify: true, + }, + swcMinify: true, + productionBrowserSourceMaps: ENABLE_SOURCE_MAPS, + excludeDefaultMomentLocales: true, + devIndicators: { + buildActivity: false, + }, + typescript: { + ignoreBuildErrors: true, + }, + eslint: { + ignoreDuringBuilds: true, + }, + compiler: { + styledComponents: true, + }, + env: clientEnv, + poweredByHeader: false, + webpack(config) { + config.experiments.topLevelAwait = true + return config + }, + transpilePackages: PRODUCTION ? transpilationListProd : transpilationListDev, + async rewrites() { + return [{ source: '/:any*', destination: '/' }] + }, +} + +const withMDX = getWithMDX({ + extension: /\.mdx?$/, + options: { + remarkPlugins: [ + remarkBreaks, + remarkImages, + remarkMath, + remarkSlug, + [remarkToc, { tight: true }], + + // [ + // require('remark-autolink-headings'), + // { + // behavior: 'prepend', + // content: { + // type: 'element', + // tagName: 'i', + // properties: { className: ['bi', 'bi-link-45deg', 'mdx-link-icon'] }, + // }, + // }, + // ], + ], + rehypePlugins: [], + }, +}) + +const withFriendlyConsole = getWithFriendlyConsole({ + clearConsole: false, + projectRoot: path.resolve(moduleRoot), +}) + +const withExtraWatch = getWithExtraWatch({ + files: [path.join(moduleRoot, 'src/types/**/*.d.ts')], + dirs: [], +}) + +const withTypeChecking = getWithTypeChecking({ + typeChecking: ENABLE_TYPE_CHECKS, + eslint: ENABLE_ESLINT, + memoryLimit: 4096, +}) + +const withRobotsTxt = getWithRobotsTxt(`User-agent: *\nDisallow:${BRANCH_NAME === 'release' ? '' : ' *'}\n`) + +export default function config(phase: string, defaultConfig: NextConfig) { + const plugins = [ + withIgnore, + withExtraWatch, + withSvg, + withFriendlyConsole, + withMDX, + withTypeChecking, + PROFILE && withoutMinification, + WATCH_POLL && withWebpackWatchPoll, + withFriendlyChunkNames, + withResolve, + withRobotsTxt, + withUrlAsset, + PRODUCTION && withoutDebugPackage, + ].filter(Boolean) + + return plugins.reduce( + (acc, plugin) => { + const update = plugin(acc) + return typeof update === 'function' ? update(phase, defaultConfig) : update + }, + { ...nextConfig }, + ) +} diff --git a/web/config/next/withCopy.ts b/web/config/next/withCopy.ts new file mode 100644 index 00000000..cc067ed8 --- /dev/null +++ b/web/config/next/withCopy.ts @@ -0,0 +1,11 @@ +import { NextConfig } from 'next' +import CopyPlugin, { PluginOptions as CopyPluginOptions } from 'copy-webpack-plugin' +import { addWebpackPlugin } from './lib/addWebpackPlugin' + +const getWithCopy = (options: CopyPluginOptions) => (nextConfig: NextConfig) => { + return addWebpackPlugin(nextConfig, new CopyPlugin(options)) +} + +export default getWithCopy + +export { type PluginOptions as CopyPluginOptions } from 'copy-webpack-plugin' diff --git a/web/config/next/withExtraWatch.ts b/web/config/next/withExtraWatch.ts new file mode 100644 index 00000000..34033efc --- /dev/null +++ b/web/config/next/withExtraWatch.ts @@ -0,0 +1,17 @@ +import type { NextConfig } from 'next' +import ExtraWatchWebpackPlugin from 'extra-watch-webpack-plugin' + +import { addWebpackPlugin } from './lib/addWebpackPlugin' + +export interface WithExtraWatchOptions { + files: string[] + dirs: string[] +} + +const getWithExtraWatch = + ({ files, dirs }: WithExtraWatchOptions) => + (nextConfig: NextConfig) => { + return addWebpackPlugin(nextConfig, new ExtraWatchWebpackPlugin({ files, dirs })) + } + +export default getWithExtraWatch diff --git a/web/config/next/withFriendlyChunkNames.ts b/web/config/next/withFriendlyChunkNames.ts new file mode 100644 index 00000000..b75373fd --- /dev/null +++ b/web/config/next/withFriendlyChunkNames.ts @@ -0,0 +1,17 @@ +import { unset } from 'lodash-es' + +import type { NextConfig } from 'next' +import { addWebpackConfig } from './lib/addWebpackConfig' + +export default function withFriendlyChunkNames(nextConfig: NextConfig) { + return addWebpackConfig(nextConfig, (nextConfig, webpackConfig, _options) => { + if ( + typeof webpackConfig.optimization?.splitChunks !== 'boolean' && + webpackConfig.optimization?.splitChunks?.cacheGroups + ) { + unset(webpackConfig, 'optimization.splitChunks.cacheGroups.lib.name') + unset(webpackConfig, 'optimization.splitChunks.cacheGroups.shared.name') + } + return webpackConfig + }) +} diff --git a/web/config/next/withFriendlyConsole.ts b/web/config/next/withFriendlyConsole.ts new file mode 100644 index 00000000..b46cd87d --- /dev/null +++ b/web/config/next/withFriendlyConsole.ts @@ -0,0 +1,43 @@ +import FriendlyErrorsWebpackPlugin from '@nuxt/friendly-errors-webpack-plugin' +import type { NextConfig } from 'next' +import { addWebpackPlugin } from './lib/addWebpackPlugin' + +interface FriendlyErrorsWebpackPluginError { + message: string + file: string +} + +function cleanup() { + return (error: FriendlyErrorsWebpackPluginError) => ({ + ...error, + message: error.message.replace(/.*ERROR in.*\n/, '').replace(/.*WARNING in.*\n/, ''), + }) +} + +function stripProjectRoot(projectRoot: string) { + return (error: FriendlyErrorsWebpackPluginError) => ({ + ...error, + message: error && error.message && error.message.replace(`${projectRoot}/`, ''), + file: error && error.file && error.file.replace(`${projectRoot}/`, ''), + }) +} + +export interface WithFriendlyConsoleParams { + clearConsole: boolean + projectRoot: string +} + +const getWithFriendlyConsole = + ({ clearConsole, projectRoot }: WithFriendlyConsoleParams) => + (nextConfig: NextConfig) => { + return addWebpackPlugin( + nextConfig, + new FriendlyErrorsWebpackPlugin({ + clearConsole, + additionalTransformers: [cleanup(), stripProjectRoot(projectRoot)], + additionalFormatters: [], + }), + ) + } + +export default getWithFriendlyConsole diff --git a/web/config/next/withIgnore.ts b/web/config/next/withIgnore.ts new file mode 100644 index 00000000..a9fb25cb --- /dev/null +++ b/web/config/next/withIgnore.ts @@ -0,0 +1,19 @@ +import webpack from 'webpack' +import type { NextConfig } from 'next' + +import { addWebpackPlugin } from './lib/addWebpackPlugin' + +export default function withIgnore(nextConfig: NextConfig) { + return addWebpackPlugin( + nextConfig, + new webpack.IgnorePlugin({ + checkResource: (resource: string) => { + return ( + resource.endsWith('awesomplete.css') || + resource.includes('core-js/library') || + resource.includes('babel-runtime') + ) + }, + }), + ) +} diff --git a/web/config/next/withResolve.ts b/web/config/next/withResolve.ts new file mode 100644 index 00000000..efbae348 --- /dev/null +++ b/web/config/next/withResolve.ts @@ -0,0 +1,24 @@ +import path from 'path' + +import type { NextConfig } from 'next' +import { findModuleRoot } from '../../lib/findModuleRoot' +import { addWebpackConfig } from './lib/addWebpackConfig' + +const { moduleRoot } = findModuleRoot() + +export default function withResolve(nextConfig: NextConfig) { + return addWebpackConfig(nextConfig, (nextConfig, webpackConfig, options) => { + webpackConfig.resolve = { + ...webpackConfig.resolve, + modules: [ + ...(webpackConfig.resolve?.modules ?? []), + path.resolve(moduleRoot), + path.resolve(moduleRoot, '..'), + path.resolve(moduleRoot, 'src'), + path.resolve(moduleRoot, 'node_modules'), + ], + } + + return webpackConfig + }) +} diff --git a/web/config/next/withRobotsTxt.ts b/web/config/next/withRobotsTxt.ts new file mode 100644 index 00000000..cccd7491 --- /dev/null +++ b/web/config/next/withRobotsTxt.ts @@ -0,0 +1,15 @@ +import { NextConfig } from 'next' +import { addWebpackPlugin } from './lib/addWebpackPlugin' +import EmitFilePlugin from './lib/EmitFilePlugin' + +export const getWithRobotsTxt = (content: string) => (nextConfig: NextConfig) => { + return addWebpackPlugin( + nextConfig, + new EmitFilePlugin({ + path: '.', + filename: 'robots.txt', + content, + hash: false, + }), + ) +} diff --git a/web/config/next/withSvg.ts b/web/config/next/withSvg.ts new file mode 100644 index 00000000..e4a0ddeb --- /dev/null +++ b/web/config/next/withSvg.ts @@ -0,0 +1,19 @@ +import type { NextConfig } from 'next' + +import { addWebpackLoader } from './lib/addWebpackLoader' + +export default function withSvg(nextConfig: NextConfig) { + return addWebpackLoader(nextConfig, (_webpackConfig, _context) => ({ + test: /\.svg$/i, + issuer: /\.(ts|tsx|js|jsx|md|mdx|scss|sass|css)$/, + use: [ + { + loader: '@svgr/webpack', + options: { + removeViewbox: false, + typescript: false, + }, + }, + ], + })) +} diff --git a/web/config/next/withTypeChecking.ts b/web/config/next/withTypeChecking.ts new file mode 100644 index 00000000..cab90e54 --- /dev/null +++ b/web/config/next/withTypeChecking.ts @@ -0,0 +1,86 @@ +import path from 'path' +import { readJSONSync } from 'fs-extra/esm' +import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin' +import ESLintWebpackPlugin from 'eslint-webpack-plugin' +import type { NextConfig } from 'next' +import { addWebpackPlugin } from './lib/addWebpackPlugin' +import { findModuleRoot } from '../../lib/findModuleRoot' + +const { moduleRoot } = findModuleRoot() + +export interface GetWithTypeCheckingParams { + eslint: boolean + typeChecking: boolean + memoryLimit?: number + exclude?: string[] +} + +const getWithTypeChecking = + ({ eslint, typeChecking, memoryLimit = 512, exclude }: GetWithTypeCheckingParams) => + (nextConfig: NextConfig) => { + let config = nextConfig + + if (!typeChecking && !eslint) { + return config + } + + if (typeChecking) { + const tsConfig = readJSONSync('tsconfig.json') + + config = addWebpackPlugin( + config, + new ForkTsCheckerWebpackPlugin({ + issue: { + exclude: exclude?.map((file) => ({ origin: 'typescript', file })), + }, + + typescript: { + configFile: path.join(moduleRoot, 'tsconfig.json'), + memoryLimit, + mode: 'write-references', + diagnosticOptions: { syntactic: true, semantic: true, declaration: true, global: true }, + configOverwrite: { + compilerOptions: { + ...tsConfig.compilerOptions, + allowJs: false, + skipLibCheck: true, + sourceMap: false, + inlineSourceMap: false, + declarationMap: false, + tsBuildInfoFile: '.cache/.tsbuildinfo.webpackplugin', + }, + include: [ + 'lib/**/*.js', + 'lib/**/*.jsx', + 'lib/**/*.ts', + 'lib/**/*.tsx', + 'src/**/*.js', + 'src/**/*.jsx', + 'src/**/*.ts', + 'src/**/*.tsx', + ], + exclude: [...tsConfig.exclude, ...(exclude ?? [])], + }, + }, + + formatter: 'codeframe', + }), + ) + } + + if (eslint) { + config = addWebpackPlugin( + config, + new ESLintWebpackPlugin({ + threads: true, + files: [path.join(moduleRoot, 'src/**/*.{js,jsx,ts,tsx}')], + cache: false, + formatter: 'codeframe', + }), + ) + } + + return config + } + +export default getWithTypeChecking diff --git a/web/config/next/withUrlAsset.ts b/web/config/next/withUrlAsset.ts new file mode 100644 index 00000000..21d38fab --- /dev/null +++ b/web/config/next/withUrlAsset.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from 'next' + +import { addWebpackLoader } from './lib/addWebpackLoader' + +export default function withUrlAsset(nextConfig: NextConfig) { + return addWebpackLoader(nextConfig, (_webpackConfig, _context) => ({ + type: 'asset', + resourceQuery: /url/, // *.svg?url + })) +} diff --git a/web/config/next/withWebpackWatchPoll.ts b/web/config/next/withWebpackWatchPoll.ts new file mode 100644 index 00000000..31af879c --- /dev/null +++ b/web/config/next/withWebpackWatchPoll.ts @@ -0,0 +1,11 @@ +import { set } from 'lodash-es' + +import type { NextConfig } from 'next' +import { addWebpackConfig } from './lib/addWebpackConfig' + +export default function withWebpackWatchPoll(nextConfig: NextConfig) { + return addWebpackConfig(nextConfig, (nextConfig, webpackConfig, options) => { + set(webpackConfig, 'watchOptions.poll', 1000) + return webpackConfig + }) +} diff --git a/web/config/next/withoutDebugPackage.ts b/web/config/next/withoutDebugPackage.ts new file mode 100644 index 00000000..ee993f41 --- /dev/null +++ b/web/config/next/withoutDebugPackage.ts @@ -0,0 +1,16 @@ +import path from 'path' +import type { NextConfig } from 'next' +import { addWebpackLoader } from './lib/addWebpackLoader' + +const THIS_DIR = new URL('.', import.meta.url).pathname + +export default function withoutDebugPackage(nextConfig: NextConfig) { + return addWebpackLoader(nextConfig, (_webpackConfig, _context) => ({ + test: /\.(ts|tsx|ctx|mtx|js|jsx|cjs|mjs|)$/i, + use: [ + { + loader: path.resolve(THIS_DIR, 'loaders', 'removeDebugPackageLoader.cjs'), + }, + ], + })) +} diff --git a/web/config/next/withoutMinification.ts b/web/config/next/withoutMinification.ts new file mode 100644 index 00000000..b87bf0a9 --- /dev/null +++ b/web/config/next/withoutMinification.ts @@ -0,0 +1,13 @@ +import { NextConfig } from 'next' +import { addWebpackConfig } from './lib/addWebpackConfig' + +export default function withoutMinification(nextConfig: NextConfig) { + return addWebpackConfig(nextConfig, (nextConfig, webpackConfig, options) => { + if (webpackConfig.optimization) { + webpackConfig.optimization.minimizer = [] + } else { + webpackConfig.optimization = { minimizer: [] } + } + return webpackConfig + }) +} diff --git a/web/config/nodemon/dev.json b/web/config/nodemon/dev.json new file mode 100644 index 00000000..82fbdc4d --- /dev/null +++ b/web/config/nodemon/dev.json @@ -0,0 +1,28 @@ +{ + "quiet": true, + "legacyWatch": false, + "delay": 0.1, + "watch": [ + "../.env", + ".browserslistrc", + ".editorconfig", + ".env", + ".eslintignore", + ".eslintrc.js", + ".prettierignore", + ".prettierrc", + "babel.config.js", + "config", + "next.config.js", + "package.json", + "postcss.config.js", + "schemas", + "stylelint.config.js", + "tsconfig.json", + "webpack.config.js", + "yarn.lock" + ], + "ext": "js,jsx,ts,tsx,json,flow,css,scss,sass,styl,html,ejs,d,txt,yml,sh", + "ignore": ["**/*.test.*s", "**/*.test.*sx", "**/__tests__", ".build", "config/jest", "node_modules"], + "exec": "yarn dev:start || cd ." +} diff --git a/web/config/nodemon/eslint.fix.json b/web/config/nodemon/eslint.fix.json new file mode 100644 index 00000000..5871f274 --- /dev/null +++ b/web/config/nodemon/eslint.fix.json @@ -0,0 +1,29 @@ +{ + "quiet": true, + "legacyWatch": false, + "delay": 0.1, + "watch": [ + "../.env", + ".browserslistrc", + ".editorconfig", + ".env", + ".eslintignore", + ".eslintrc.js", + ".prettierignore", + ".prettierrc", + "babel.config.js", + "config", + "cypress", + "jest.config.js", + "package.json", + "postcss.config.js", + "src", + "stylelint.config.js", + "tsconfig.json", + "webpack.config.js", + "yarn.lock" + ], + "ext": "js,jsx,ts,tsx,json,flow,css,scss,sass,styl,html,ejs,d,txt,yml,sh", + "ignore": ["**/*.test.*s", "**/*.test.*sx", "**/__tests__", ".build", "config/jest", "node_modules"], + "exec": "yarn clear && yarn eslint:fix && echo '\u2714 OK' || echo '\u274C FAILED' || cd ." +} diff --git a/web/config/nodemon/eslint.json b/web/config/nodemon/eslint.json new file mode 100644 index 00000000..cfd50a9b --- /dev/null +++ b/web/config/nodemon/eslint.json @@ -0,0 +1,29 @@ +{ + "quiet": true, + "legacyWatch": false, + "delay": 0.1, + "watch": [ + "../.env", + ".browserslistrc", + ".editorconfig", + ".env", + ".eslintignore", + ".eslintrc.js", + ".prettierignore", + ".prettierrc", + "babel.config.js", + "config", + "cypress", + "jest.config.js", + "package.json", + "postcss.config.js", + "src", + "stylelint.config.js", + "tsconfig.json", + "webpack.config.js", + "yarn.lock" + ], + "ext": "js,jsx,ts,tsx,json,flow,css,scss,sass,styl,html,ejs,d,txt,yml,sh", + "ignore": ["**/*.test.*s", "**/*.test.*sx", "**/__tests__", ".build", "config/jest", "node_modules"], + "exec": "yarn clear && yarn eslint && echo '\u2714 OK' || echo '\u274C FAILED' || cd ." +} diff --git a/web/config/nodemon/lint.fix.json b/web/config/nodemon/lint.fix.json new file mode 100644 index 00000000..c6957500 --- /dev/null +++ b/web/config/nodemon/lint.fix.json @@ -0,0 +1,27 @@ +{ + "quiet": true, + "legacyWatch": false, + "delay": 0.1, + "watch": [ + "../.env", + ".browserslistrc", + ".editorconfig", + ".env", + ".eslintignore", + ".eslintrc.js", + ".prettierignore", + ".prettierrc", + "babel.config.js", + "config", + "cypress", + "jest.config.js", + "package.json", + "src", + "tsconfig.json", + "webpack.config.js", + "yarn.lock" + ], + "ext": "js,jsx,ts,tsx,json,flow,css,scss,sass,styl,html,ejs,d,txt,yml,sh", + "ignore": ["**/*.test.*s", "**/*.test.*sx", "**/__tests__", ".build", "config/jest", "node_modules"], + "exec": "yarn clear && yarn lint:fix && echo '\u2714 OK' || echo '\u274C FAILED' || cd ." +} diff --git a/web/config/nodemon/lint.json b/web/config/nodemon/lint.json new file mode 100644 index 00000000..8fe3388c --- /dev/null +++ b/web/config/nodemon/lint.json @@ -0,0 +1,27 @@ +{ + "quiet": true, + "legacyWatch": false, + "delay": 0.1, + "watch": [ + "../.env", + ".browserslistrc", + ".editorconfig", + ".env", + ".eslintignore", + ".eslintrc.js", + ".prettierignore", + ".prettierrc", + "babel.config.js", + "config", + "cypress", + "jest.config.js", + "package.json", + "src", + "tsconfig.json", + "webpack.config.js", + "yarn.lock" + ], + "ext": "js,jsx,ts,tsx,json,flow,css,scss,sass,styl,html,ejs,d,txt,yml,sh", + "ignore": ["**/*.test.*s", "**/*.test.*sx", "**/__tests__", ".build", "config/jest", "node_modules"], + "exec": "yarn clear && yarn lint && echo '\u2714 OK' || echo '\u274C FAILED' || cd ." +} diff --git a/web/config/nodemon/prod.json b/web/config/nodemon/prod.json new file mode 100644 index 00000000..4d75ef28 --- /dev/null +++ b/web/config/nodemon/prod.json @@ -0,0 +1,37 @@ +{ + "quiet": true, + "legacyWatch": false, + "delay": 0.1, + "watch": [ + "../.env", + ".browserslistrc", + ".editorconfig", + ".env", + ".eslintignore", + ".eslintrc.js", + ".prettierignore", + ".prettierrc", + "babel.config.js", + "config", + "package.json", + "postcss.config.js", + "schemas", + "src", + "static", + "stylelint.config.js", + "tsconfig.json", + "webpack.config.js", + "yarn.lock" + ], + "ext": "js,jsx,ts,tsx,json,flow,css,scss,sass,styl,html,ejs,d,txt,yml,sh", + "ignore": [ + "**/*.test.*s", + "**/*.test.*sx", + "**/__tests__", + ".build", + "config/jest", + "node_modules", + "src/.generated" + ], + "exec": "yarn prod:start || cd ." +} diff --git a/web/config/nodemon/profile.json b/web/config/nodemon/profile.json new file mode 100644 index 00000000..3f444088 --- /dev/null +++ b/web/config/nodemon/profile.json @@ -0,0 +1,37 @@ +{ + "quiet": true, + "legacyWatch": false, + "delay": 0.1, + "watch": [ + "../.env", + ".browserslistrc", + ".editorconfig", + ".env", + ".eslintignore", + ".eslintrc.js", + ".prettierignore", + ".prettierrc", + "babel.config.js", + "config", + "package.json", + "postcss.config.js", + "schemas", + "src", + "static", + "stylelint.config.js", + "tsconfig.json", + "webpack.config.js", + "yarn.lock" + ], + "ext": "js,jsx,ts,tsx,json,flow,css,scss,sass,styl,html,ejs,d,txt,yml,sh", + "ignore": [ + "**/*.test.*s", + "**/*.test.*sx", + "**/__tests__", + ".build", + "config/jest", + "node_modules", + "src/.generated" + ], + "exec": "run-s -c clear prod:clean prod:start:profile || cd ." +} diff --git a/web/config/nodemon/serve.json b/web/config/nodemon/serve.json new file mode 100644 index 00000000..cc9fbfcf --- /dev/null +++ b/web/config/nodemon/serve.json @@ -0,0 +1,17 @@ +{ + "quiet": true, + "legacyWatch": false, + "delay": 0.1, + "watch": [ + ".env", + "babel.config.js", + "infra/lambda-at-edge/modifyOutgoingHeaders.lambda.js", + "package.json", + "tools/server", + "tsconfig.json", + "yarn.lock" + ], + "ext": "js,jsx,ts,tsx,json,flow,css,scss,sass,styl,html,ejs,d,txt,yml,sh", + "ignore": ["**/*.test.*s", "**/*.test.*sx", "**/__tests__", ".build", "config/jest", "node_modules"], + "exec": "yarn prod:serve:nowatch || cd ." +} diff --git a/web/infra/data/lambda-at-edge/OriginRequest.lambda.cjs b/web/infra/data/lambda-at-edge/OriginRequest.lambda.cjs new file mode 100644 index 00000000..0d6e1cae --- /dev/null +++ b/web/infra/data/lambda-at-edge/OriginRequest.lambda.cjs @@ -0,0 +1,42 @@ +/* eslint-disable prefer-destructuring,sonarjs/no-collapsible-if,unicorn/no-lonely-if */ +// Implements rewrite of non-compressed to .gz URLs using AWS +// Lambda@Edge. This is useful if you have precompressed your files. +// +// Usage: +// Create an AWS Lambda function and attach it to "Origin Request" event of a +// Cloudfront distribution + +const ARCHIVE_EXTS = ['.7z', '.br', '.bz2', '.gz', '.lzma', '.xz', '.zip', '.zst'] + +function getHeader(headers, headerName) { + const header = headers[headerName.toLowerCase()] + if (!header || !header[0] || !header[0].value) { + return undefined + } + return header[0].value +} + +function acceptsEncoding(headers, encoding) { + const ae = getHeader(headers, 'Accept-Encoding') + if (!ae || typeof ae != 'string') { + return false + } + return ae.split(',').some((e) => e.trim().toLowerCase().startsWith(encoding.toLowerCase())) +} + +function handler(event, context, callback) { + const request = event.Records[0].cf.request + const headers = request.headers + + // If not an archive file (which are not precompressed), rewrite the URL to + // get the corresponding .gz file + if (ARCHIVE_EXTS.every((ext) => !request.uri.endsWith(ext))) { + if (acceptsEncoding(headers, 'gzip')) { + request.uri += '.gz' + } + } + + callback(null, request) +} + +exports.handler = handler diff --git a/web/infra/data/lambda-at-edge/ViewerResponse.lambda.cjs b/web/infra/data/lambda-at-edge/ViewerResponse.lambda.cjs new file mode 100644 index 00000000..b4b5b0e2 --- /dev/null +++ b/web/infra/data/lambda-at-edge/ViewerResponse.lambda.cjs @@ -0,0 +1,63 @@ +// Adds additional headers to the response, including security headers and CORS. +// Suited for serving data and APIs. +// +// See also: +// - https://securityheaders.com/ +// +// Usage: Create an AWS Lambda@Edge function and attach it to "Viewer Response" +// event of a Cloudfront distribution + +const NEW_HEADERS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + 'Content-Security-Policy': `default-src 'none'; frame-ancestors 'none'`, + 'Strict-Transport-Security': 'max-age=15768000; includeSubDomains; preload', + 'X-Content-Type-Options': 'nosniff', + 'X-DNS-Prefetch-Control': 'off', + 'X-Download-Options': 'noopen', + 'X-Frame-Options': 'DENY', + 'X-XSS-Protection': '1; mode=block', +} + +function addHeaders(headersObject) { + return Object.fromEntries( + Object.entries(headersObject).map(([header, value]) => [header.toLowerCase(), [{ key: header, value }]]), + ) +} + +const HEADERS_TO_REMOVE = new Set(['server', 'via']) + +function filterHeaders(headers) { + return Object.entries(headers).reduce((result, [key, value]) => { + if (HEADERS_TO_REMOVE.has(key.toLowerCase())) { + return result + } + + if (key.toLowerCase().includes('powered-by')) { + return result + } + + return { ...result, [key.toLowerCase()]: value } + }, {}) +} + +function modifyHeaders({ response }) { + let newHeaders = addHeaders(NEW_HEADERS) + + newHeaders = { + ...response.headers, + ...newHeaders, + } + + newHeaders = filterHeaders(newHeaders) + + return newHeaders +} + +exports.handler = (event, context, callback) => { + const { request, response } = event.Records[0].cf + response.headers = modifyHeaders({ request, response }) + callback(null, response) +} + +exports.modifyHeaders = modifyHeaders diff --git a/web/infra/find-lambda-at-edge-logs.sh b/web/infra/find-lambda-at-edge-logs.sh new file mode 100755 index 00000000..b9c8fe44 --- /dev/null +++ b/web/infra/find-lambda-at-edge-logs.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Inspired by https://stackoverflow.com/a/54096479/526860 + +FUNCTION_NAME=$1 + +for region in $(aws --output text ec2 describe-regions | cut -f 4); do + for loggroup in $(aws --output text logs describe-log-groups --log-group-name "/aws/lambda/us-east-1.$FUNCTION_NAME" --region $region --query 'logGroups[].logGroupName'); do + printf "$region\tconsole.aws.amazon.com/cloudwatch/home?region=$region#logsV2:log-groups/log-group/\$252Faws\$252Flambda\$252Fus-east-1.$FUNCTION_NAME\n" + done +done diff --git a/web/infra/iam-role-trust-for-lambda-at-edge.json b/web/infra/iam-role-trust-for-lambda-at-edge.json new file mode 100644 index 00000000..e39c7310 --- /dev/null +++ b/web/infra/iam-role-trust-for-lambda-at-edge.json @@ -0,0 +1,15 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": [ + "lambda.amazonaws.com", + "edgelambda.amazonaws.com" + ] + }, + "Action": "sts:AssumeRole" + } + ] +} diff --git a/web/infra/s3-cors-permissions.json b/web/infra/s3-cors-permissions.json new file mode 100644 index 00000000..d705ef49 --- /dev/null +++ b/web/infra/s3-cors-permissions.json @@ -0,0 +1,9 @@ +[ + { + "AllowedHeaders": ["*"], + "AllowedMethods": ["GET", "HEAD"], + "AllowedOrigins": ["*"], + "ExposeHeaders": [], + "MaxAgeSeconds": 3000 + } +] diff --git a/web/infra/web/lambda-at-edge/OriginRequest.lambda.cjs b/web/infra/web/lambda-at-edge/OriginRequest.lambda.cjs new file mode 100644 index 00000000..902e15aa --- /dev/null +++ b/web/infra/web/lambda-at-edge/OriginRequest.lambda.cjs @@ -0,0 +1,44 @@ +/* eslint-disable prefer-destructuring */ +// Implements rewrite of non-compressed to .gz or .br URLs using AWS +// Lambda@Edge. This is useful if you have precompressed your files. +// +// Usage: +// Create an AWS Lambda function and attach it to "Origin Request" event of a +// Cloudfront distribution + +const ARCHIVE_EXTS = ['.7z', '.br', '.bz2', '.gz', '.lzma', '.xz', '.zip', '.zst'] + +function getHeader(headers, headerName) { + const header = headers[headerName.toLowerCase()] + if (!header || !header[0] || !header[0].value) { + return undefined + } + return header[0].value +} + +function acceptsEncoding(headers, encoding) { + const ae = getHeader(headers, 'Accept-Encoding') + if (!ae || typeof ae != 'string') { + return false + } + return ae.split(',').some((e) => e.trim().toLowerCase().startsWith(encoding.toLowerCase())) +} + +function handler(event, context, callback) { + const request = event.Records[0].cf.request + const headers = request.headers + + // If not an archive file (which are not precompressed), rewrite the URL to + // get the corresponding .gz file + if (ARCHIVE_EXTS.every((ext) => !request.uri.endsWith(ext))) { + if (acceptsEncoding(headers, 'br')) { + request.uri += '.br' + } else if (acceptsEncoding(headers, 'gzip')) { + request.uri += '.gz' + } + } + + callback(null, request) +} + +exports.handler = handler diff --git a/web/infra/web/lambda-at-edge/ViewerResponse.lambda.cjs b/web/infra/web/lambda-at-edge/ViewerResponse.lambda.cjs new file mode 100644 index 00000000..92bfc51e --- /dev/null +++ b/web/infra/web/lambda-at-edge/ViewerResponse.lambda.cjs @@ -0,0 +1,99 @@ +// Adds additional headers to the response, including security headers. +// Suited for websites. +// +// See also: +// - https://securityheaders.com/ +// +// Usage: Create an AWS Lambda@Edge function and attach it to "Viewer Response" +// event of a Cloudfront distribution + +const FEATURE_POLICY = { + accelerometer: `'none'`, + camera: `'none'`, + geolocation: `'none'`, + gyroscope: `'none'`, + magnetometer: `'none'`, + microphone: `'none'`, + payment: `'none'`, + usb: `'none'`, +} + +function generateFeaturePolicyHeader(featurePolicyObject) { + return Object.entries(featurePolicyObject) + .map(([policy, value]) => `${policy} ${value}`) + .join('; ') +} + +const PERMISSIONS_POLICY = { + 'accelerometer': '()', + 'camera': '()', + 'geolocation': '()', + 'gyroscope': '()', + 'magnetometer': '()', + 'microphone': '()', + 'payment': '()', + 'usb': '()', + 'interest-cohort': '()', +} + +function generatePermissionsPolicyHeader(permissionsPolicyObject) { + return Object.entries(permissionsPolicyObject) + .map(([policy, value]) => `${policy}=${value}`) + .join(', ') +} + +const NEW_HEADERS = { + 'Content-Security-Policy': `default-src 'self' *.pangenome.org; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: *.pangenome.org plausible.io maxcdn.bootstrapcdn.com; style-src 'self' 'unsafe-inline' maxcdn.bootstrapcdn.com fonts.googleapis.com; font-src 'self' maxcdn.bootstrapcdn.com fonts.googleapis.com fonts.gstatic.com;img-src 'self' data:; connect-src *; frame-src 'self' player.vimeo.com`, + 'Referrer-Policy': 'no-referrer', + 'Strict-Transport-Security': 'max-age=15768000; includeSubDomains; preload', + 'X-Content-Type-Options': 'nosniff', + 'X-DNS-Prefetch-Control': 'off', + 'X-Download-Options': 'noopen', + 'X-Frame-Options': 'SAMEORIGIN', + 'X-XSS-Protection': '1; mode=block', + 'Feature-Policy': generateFeaturePolicyHeader(FEATURE_POLICY), + 'Permissions-Policy': generatePermissionsPolicyHeader(PERMISSIONS_POLICY), +} + +function addHeaders(headersObject) { + return Object.fromEntries( + Object.entries(headersObject).map(([header, value]) => [header.toLowerCase(), [{ key: header, value }]]), + ) +} + +const HEADERS_TO_REMOVE = new Set(['server', 'via']) + +function filterHeaders(headers) { + return Object.entries(headers).reduce((result, [key, value]) => { + if (HEADERS_TO_REMOVE.has(key.toLowerCase())) { + return result + } + + if (key.toLowerCase().includes('powered-by')) { + return result + } + + return { ...result, [key.toLowerCase()]: value } + }, {}) +} + +function modifyHeaders({ response }) { + let newHeaders = addHeaders(NEW_HEADERS) + + newHeaders = { + ...response.headers, + ...newHeaders, + } + + newHeaders = filterHeaders(newHeaders) + + return newHeaders +} + +exports.handler = (event, context, callback) => { + const { request, response } = event.Records[0].cf + response.headers = modifyHeaders({ request, response }) + callback(null, response) +} + +exports.modifyHeaders = modifyHeaders diff --git a/web/json-autotranslate.json b/web/json-autotranslate.json new file mode 100644 index 00000000..e93f8e85 --- /dev/null +++ b/web/json-autotranslate.json @@ -0,0 +1,4 @@ +{ + "region": "us-east-1", + "maxAttempts": 10 +} diff --git a/web/lib/clear.js b/web/lib/clear.js new file mode 100644 index 00000000..3851d79c --- /dev/null +++ b/web/lib/clear.js @@ -0,0 +1,6 @@ +// Taken from jest: +// https://github.com/facebook/jest/blob/v19.0.2/packages/jest-cli/src/constants.js#L15 +const isWindows = process.platform === 'win32' +const CLEAR = isWindows ? '\u001Bc' : '\u001B[2J\u001B[3J\u001B[H' + +process.stdout.write(CLEAR) diff --git a/web/lib/findModuleRoot.d.ts b/web/lib/findModuleRoot.d.ts new file mode 100644 index 00000000..0bb4b57e --- /dev/null +++ b/web/lib/findModuleRoot.d.ts @@ -0,0 +1,8 @@ +import { JSONSchemaForNPMPackageJsonFiles } from '@schemastore/package' + +export interface FindModuleRootResult { + moduleRoot: string + pkg: JSONSchemaForNPMPackageJsonFiles +} + +export declare function findModuleRoot(maxDepth: number = 10): FindModuleRootResult diff --git a/web/lib/findModuleRoot.js b/web/lib/findModuleRoot.js new file mode 100644 index 00000000..02da8099 --- /dev/null +++ b/web/lib/findModuleRoot.js @@ -0,0 +1,18 @@ +/* eslint-disable no-loops/no-loops,no-param-reassign,no-plusplus */ +import fs from 'fs-extra' +import path from 'path' + +const THIS_DIR = new URL('.', import.meta.url).pathname + +export function findModuleRoot(maxDepth = 10) { + let moduleRoot = THIS_DIR + while (--maxDepth) { + moduleRoot = path.resolve(moduleRoot, '..') + const file = path.join(moduleRoot, 'package.json') + if (fs.existsSync(file)) { + const pkg = fs.readJsonSync(file) + return { moduleRoot, pkg } + } + } + throw new Error('Module root not found') +} diff --git a/web/lib/getBuildNumber.ts b/web/lib/getBuildNumber.ts new file mode 100644 index 00000000..2e423974 --- /dev/null +++ b/web/lib/getBuildNumber.ts @@ -0,0 +1,5 @@ +import { getenv } from './getenv' + +export function getBuildNumber() { + return getenv('TRAVIS_BUILD_NUMBER', null) ?? getenv('BUILD_ID', null) +} diff --git a/web/lib/getBuildUrl.ts b/web/lib/getBuildUrl.ts new file mode 100644 index 00000000..57d9c839 --- /dev/null +++ b/web/lib/getBuildUrl.ts @@ -0,0 +1,5 @@ +import { getenv } from './getenv' + +export function getBuildUrl() { + return getenv('TRAVIS_BUILD_WEB_URL', null) +} diff --git a/web/lib/getDomain.ts b/web/lib/getDomain.ts new file mode 100644 index 00000000..01dca9b8 --- /dev/null +++ b/web/lib/getDomain.ts @@ -0,0 +1,36 @@ +import { isNil } from 'lodash-es' +import { getenv } from './getenv' + +const WEB_PORT_DEV = getenv('WEB_PORT_DEV', null) +const WEB_PORT_PROD = getenv('WEB_PORT_PROD', null) +const devDomain = `http://localhost:${WEB_PORT_DEV}` +const prodDomain = `http://localhost:${WEB_PORT_PROD}` + +const ENV_VARS = [ + // prettier-ignore + 'VERCEL_URL', + 'NOW_URL', + 'ZEIT_URL', + 'DEPLOY_PRIME_URL', + 'DEPLOY_URL', + 'URL', +] + +export function getenvFirst(vars: string[]): string | undefined { + return vars.map((v) => getenv(v, null)).find((v) => !isNil(v)) +} + +function sanitizeDomain(domain: string) { + return domain.startsWith('http') ? domain : `https://${domain}` +} + +export function getDomain() { + const domain = getenv('FULL_DOMAIN') + if (domain === 'autodetect') { + if (process.env.NODE_ENV === 'development') { + return devDomain + } + return sanitizeDomain(getenvFirst(ENV_VARS) ?? prodDomain) + } + return sanitizeDomain(domain) +} diff --git a/web/lib/getGitBranch.ts b/web/lib/getGitBranch.ts new file mode 100644 index 00000000..2f5a4455 --- /dev/null +++ b/web/lib/getGitBranch.ts @@ -0,0 +1,28 @@ +import { execSync } from 'child_process' + +import { getenv } from './getenv' + +export function getGitCommitHashLocal() { + try { + return execSync('git rev-parse --abbrev-ref HEAD').toString().trim() + } catch { + return undefined + } +} + +export function getGitBranch() { + return ( + getenv('GIT_BRANCH', null) ?? + getenv('BRANCH', null) ?? + getenv('TRAVIS_BRANCH', null) ?? + getenv('NOW_GITHUB_COMMIT_REF', null) ?? + getenv('VERCEL_GITHUB_COMMIT_REF', null) ?? + getenv('VERCEL_GITLAB_COMMIT_REF', null) ?? + getenv('VERCEL_BITBUCKET_COMMIT_REF', null) ?? + getenv('ZEIT_GITHUB_COMMIT_REF', null) ?? + getenv('ZEIT_GITLAB_COMMIT_REF', null) ?? + getenv('ZEIT_BITBUCKET_COMMIT_REF', null) ?? + getGitCommitHashLocal() ?? + '' + ) +} diff --git a/web/lib/getGitCommitHash.ts b/web/lib/getGitCommitHash.ts new file mode 100644 index 00000000..0311d911 --- /dev/null +++ b/web/lib/getGitCommitHash.ts @@ -0,0 +1,29 @@ +import { execSync } from 'child_process' + +import { getenv } from './getenv' + +export function getGitCommitHashLocal() { + try { + return execSync('git rev-parse HEAD').toString().trim() + } catch { + return undefined + } +} + +export function getGitCommitHash() { + return ( + getenv('GIT_COMMIT', null) ?? + getenv('GIT_COMMIT_HASH', null) ?? + getenv('TRAVIS_COMMIT', null) ?? + getenv('NOW_GITHUB_COMMIT_SHA', null) ?? + getenv('GITHUB_SHA', null) ?? + getenv('COMMIT_REF', null) ?? + getenv('VERCEL_GITHUB_COMMIT_SHA', null) ?? + getenv('VERCEL_GITLAB_COMMIT_SHA', null) ?? + getenv('VERCEL_BITBUCKET_COMMIT_SHA', null) ?? + getenv('ZEIT_GITHUB_COMMIT_SHA', null) ?? + getenv('ZEIT_GITLAB_COMMIT_SHA', null) ?? + getenv('ZEIT_BITBUCKET_COMMIT_SHA', null) ?? + getGitCommitHashLocal() + ) +} diff --git a/web/lib/getenv.d.ts b/web/lib/getenv.d.ts new file mode 100644 index 00000000..040f8be0 --- /dev/null +++ b/web/lib/getenv.d.ts @@ -0,0 +1,3 @@ +export function getenv(key: string, defaultValue?: string | null): string + +export function getbool(key: string, defaultValue?: boolean | null): boolean diff --git a/web/lib/getenv.js b/web/lib/getenv.js new file mode 100644 index 00000000..3821e680 --- /dev/null +++ b/web/lib/getenv.js @@ -0,0 +1,47 @@ +import '../config/dotenv' + +export default class EnvVarError extends TypeError { + constructor(key, value) { + super(` + When reading an environment variable "${key}" (as \`process.env.${key}\`): + it was expected to find a valid string, but found \`${value}\`. + + Have you followed the instructions in Developer's guide? + + There might have been additions to the list of environement variables required. + Verify that your \`.env\` file has all the variables present in \`.env.example\`: + + diff --color .env.example .env + + In simple cases you might just copy the example: + + cp .env.example .env + + `) + } +} + +export function getenv(key, defaultValue) { + const value = process.env[key] + if (!value) { + if (typeof defaultValue !== 'undefined') { + return defaultValue + } + + throw new EnvVarError(key, value) + } + return value +} + +export function getbool(key, defaultValue) { + const value = process.env[key] + if (!value) { + if (typeof defaultValue !== 'undefined') { + return defaultValue + } + + throw new EnvVarError(key, value) + } + + return value === '1' || value === 'true' || value === 'yes' +} diff --git a/web/next-env.d.ts b/web/next-env.d.ts new file mode 100644 index 00000000..4f11a03d --- /dev/null +++ b/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/web/next.config.mjs b/web/next.config.mjs new file mode 100644 index 00000000..80f1f429 --- /dev/null +++ b/web/next.config.mjs @@ -0,0 +1,3 @@ +import config from './config/next/next.config.ts' + +export default config diff --git a/web/package.json b/web/package.json new file mode 100644 index 00000000..00216142 --- /dev/null +++ b/web/package.json @@ -0,0 +1,338 @@ +{ + "name": "@neherlab/pangraph", + "version": "0.1.0", + "description": "Align large sets of closely related genomes into a graph data structure", + "license": "MIT", + "type": "module", + "scripts": { + "clear": "node ./lib/clear.js", + "dev": "nodemon --config config/nodemon/dev.json", + "dev:start": "yarn install && run-s clear dev:clean monkey-patch dev:nowatch || cd .", + "dev:nowatch": "yarn next dev --hostname 0.0.0.0 --port 3000", + "dev:clean": "rimraf '.build/development/{*,.*}' 'node_modules/.cache'", + "prod": "yarn prod:watch", + "prod:build": "yarn install && run-s clear prod:clean monkey-patch next:build next:export", + "prod:build:ci": "yarn run prod:build", + "prod:build:vercel": "cp .env.vercel .env && yarn install && yarn prod:build && cp .build/production/tmp/routes-manifest.json .build/production/web/", + "next": "yarn run:node node_modules/next/dist/bin/next.js", + "next:build": "yarn next build", + "next:export": "yarn next export --threads 4 -o \".build/production/web\" && cp .build/production/tmp/robots.txt .build/production/web/robots.txt", + "next:build:profile": "PROFILE=1 yarn next:build --profile", + "prod:clean": "rimraf '.build/production/{*,.*}' 'node_modules/.cache'", + "prod:serve:nowatch": "yarn run:node ./tools/server/server.ts", + "prod:serve": "nodemon --config config/nodemon/serve.json", + "prod:watch": "nodemon --config config/nodemon/prod.json", + "prod:start": "yarn install && run-s -c prod:build prod:serve || cd .", + "prod:build:profile": "yarn run monkey-patch && yarn run next:build:profile && yarn run next:export", + "prod:watch:profile": "nodemon --config config/nodemon/profile.json", + "prod:start:profile": "yarn prod:build:profile && yarn prod:serve || cd .", + "analyze": "cross-env ANALYZE=1 yarn prod:build", + "analyze:clean": "rimraf '.build/analyze/*' 'node_modules/.cache'", + "analyze:watch": "nodemon --config config/nodemon/analyze.json", + "lint": "run-s -c eslint tsc", + "lint:fix": "run-s -c eslint:fix tsc", + "lint:watch": "nodemon --config config/nodemon/lint.json", + "lint:fix:watch": "nodemon --config config/nodemon/lint.fix.json", + "lint:ci": "run-s -c eslint:ci tsc", + "eslint": "eslint --format codeframe \"**/*.{js,jsx,ts,tsx}\"", + "eslint:i18n": "eslint --config .eslintrc.i18next.js --format codeframe \"src/{components,pages}/**/*.{js,jsx,ts,tsx}\"", + "eslint:fix": "yarn eslint --fix", + "eslint:watch": "nodemon --config config/nodemon/eslint.json", + "eslint:fix:watch": "nodemon --config config/nodemon/eslint.fix.json", + "eslint:ci": "yarn eslint --max-warnings=0", + "tsc": "tsc --project tsconfig.json --noEmit", + "tsc:watch": "yarn tsc --watch", + "format": "prettier --check \"**/*.{js,jsx,ts,tsx,json,html,css,less,scss,yml,yaml}\"", + "format:fix": "yarn format --write", + "test": "yarn test:nowatch --watch --verbose ", + "test:nowatch": "jest --config=config/jest/jest.config.js --passWithNoTests", + "test:lint": "WITH_ESLINT=1 yarn test", + "test:lint:nowatch": "WITH_ESLINT=1 yarn test:nowatch", + "test:debug": "node --inspect-brk node_modules/.bin/jest --config=config/jest/jest.config.js --runInBand --watch", + "test:coverage": "yarn test:nowatch --coverage", + "run:node": "cross-env NODE_OPTIONS='--max_old_space_size=8192 --trace-warnings' yarn ts-node-esm --cwdMode -P tsconfig.ts-node.json", + "monkey-patch": "yarn run:node tools/monkeyPatch.ts", + "serve:data": "yarn run:node ./tools/server/dataServer.ts", + "i18n:extract": "yarn i18next -c config/i18next/i18next.config.cjs", + "i18n:addkeys": "yarn run:node tools/addLocaleKeys.ts", + "i18n:fix": "yarn run:node tools/fixLocales.ts", + "i18n:translate": "json-autotranslate --config=json-autotranslate.json --input=src/i18n/resources --cache=.cache/json-autotranslate --service=amazon-translate --matcher=i18next --fix-inconsistencies --delete-unused-strings" + }, + "dependencies": { + "@mdx-js/react": "2.3.0", + "@react-spring/core": "9.7.0", + "@tanstack/query-core": "4.24.10", + "@tanstack/react-query": "4.24.10", + "@tanstack/react-query-devtools": "4.24.10", + "@tweenjs/tween.js": "18.6.4", + "animate.css": "4.1.1", + "autoprefixer": "10.4.13", + "axios": "1.3.4", + "bootstrap": "4.5.2", + "bootstrap-icons": "1.10.3", + "classnames": "2.3.2", + "color": "4.2.3", + "core-js": "3.28.0", + "countries-list": "2.6.1", + "country-flag-icons": "1.5.5", + "fast-copy": "3.0.1", + "fast-memoize": "2.5.2", + "fasy": "9.0.2", + "fuse.js": "6.6.2", + "i18next": "22.4.10", + "is-absolute-url": "4.0.1", + "iso-3166-1-alpha-2": "1.0.1", + "lodash-es": "4.17.21", + "luxon": "3.2.1", + "marked": "4.2.12", + "merge-anything": "5.1.4", + "next": "13.2.1", + "next-progress": "2.2.0", + "numbro": "2.3.6", + "papaparse": "5.3.2", + "polished": "4.2.2", + "pretty-bytes": "6.1.0", + "prop-types": "15.8.1", + "rc-slider": "10.1.1", + "react": "18.2.0", + "react-aspect-ratio": "1.1.4", + "react-copy-to-clipboard": "5.1.0", + "react-dom": "18.2.0", + "react-error-boundary": "3.1.4", + "react-helmet": "6.1.0", + "react-i18next": "12.1.5", + "react-icons": "4.7.1", + "react-loader-spinner": "5.3.4", + "react-reactstrap-pagination": "2.0.3", + "react-resize-detector": "8.0.4", + "react-select": "5.7.0", + "react-share": "4.4.1", + "react-toggle": "4.1.3", + "react-use": "17.4.0", + "reactstrap": "8.10.1", + "recharts": "2.4.3", + "recoil": "0.7.6", + "recoil-persist": "4.2.0", + "reflect-metadata": "0.1.13", + "regenerator-runtime": "0.13.11", + "route-parser": "0.0.5", + "serialize-javascript": "6.0.1", + "styled-components": "5.3.6", + "three": "0.150.0", + "three-spritetext": "1.7.1", + "three-stdlib": "2.21.8", + "transliteration": "2.3.5", + "typeface-droid-sans-mono": "0.0.44", + "typeface-open-sans": "1.1.13", + "url-join": "5.0.0", + "use-sync-external-store": "1.2.0" + }, + "devDependencies": { + "@mdx-js/loader": "2.3.0", + "@next/eslint-plugin-next": "13.2.1", + "@next/mdx": "13.2.1", + "@nuxt/friendly-errors-webpack-plugin": "2.5.2", + "@schemastore/package": "0.0.8", + "@svgr/webpack": "6.5.1", + "@swc/core": "1.3.36", + "@testing-library/jest-dom": "5.16.5", + "@testing-library/react": "14.0.0", + "@testing-library/user-event": "14.4.3", + "@types/classnames": "2.3.0", + "@types/color": "3.0.3", + "@types/compression": "1.7.2", + "@types/copy-webpack-plugin": "8.0.1", + "@types/express": "4.17.17", + "@types/extra-watch-webpack-plugin": "1.0.3", + "@types/friendly-errors-webpack-plugin": "0.1.4", + "@types/fs-extra": "11.0.1", + "@types/jest": "29.4.0", + "@types/jest-axe": "3.5.5", + "@types/lodash-es": "4.17.6", + "@types/luxon": "3.2.0", + "@types/morgan": "1.9.4", + "@types/node": "18.14.1", + "@types/papaparse": "5.3.7", + "@types/react": "18.0.28", + "@types/react-copy-to-clipboard": "5.0.4", + "@types/react-dom": "18.0.11", + "@types/react-loader-spinner": "3.1.3", + "@types/react-toggle": "4.0.3", + "@types/rimraf": "3.0.2", + "@types/route-parser": "0.1.4", + "@types/serialize-javascript": "5.0.2", + "@types/styled-components": "5.1.26", + "@types/three": "0.149.0", + "@types/url-join": "4.0.1", + "@types/use-sync-external-store": "0.0.3", + "@typescript-eslint/eslint-plugin": "5.53.0", + "@typescript-eslint/parser": "5.53.0", + "@typescript-eslint/typescript-estree": "5.53.0", + "allow-methods": "4.1.3", + "compression": "1.7.4", + "copy-webpack-plugin": "11.0.0", + "cross-env": "7.0.3", + "css-loader": "6.7.3", + "dotenv": "16.0.3", + "eslint": "8.34.0", + "eslint-config-airbnb": "19.0.4", + "eslint-config-airbnb-base": "15.0.0", + "eslint-config-airbnb-typescript": "17.0.0", + "eslint-config-next": "13.2.1", + "eslint-config-prettier": "8.6.0", + "eslint-config-react-app": "7.0.1", + "eslint-formatter-codeframe": "7.32.1", + "eslint-import-resolver-typescript": "3.5.3", + "eslint-plugin-array-func": "3.1.8", + "eslint-plugin-cflint": "1.0.0", + "eslint-plugin-cypress": "2.12.1", + "eslint-plugin-flowtype": "8.0.3", + "eslint-plugin-import": "2.27.5", + "eslint-plugin-jest": "27.2.1", + "eslint-plugin-jest-dom": "4.0.3", + "eslint-plugin-json": "3.1.0", + "eslint-plugin-jsx-a11y": "6.7.1", + "eslint-plugin-lodash": "7.4.0", + "eslint-plugin-no-loops": "0.3.0", + "eslint-plugin-no-secrets": "0.8.9", + "eslint-plugin-node": "11.1.0", + "eslint-plugin-only-ascii": "0.0.0", + "eslint-plugin-only-warn": "1.1.0", + "eslint-plugin-prettier": "4.2.1", + "eslint-plugin-promise": "6.1.1", + "eslint-plugin-react": "7.32.2", + "eslint-plugin-react-hooks": "4.6.0", + "eslint-plugin-react-perf": "3.3.1", + "eslint-plugin-security": "1.7.1", + "eslint-plugin-sonarjs": "0.18.0", + "eslint-plugin-unicorn": "45.0.2", + "eslint-webpack-plugin": "4.0.0", + "express": "4.18.2", + "express-static-gzip": "2.1.7", + "extra-watch-webpack-plugin": "1.0.3", + "file-loader": "6.2.0", + "fork-ts-checker-webpack-plugin": "7.3.0", + "fs-extra": "11.1.0", + "glob": "8.1.0", + "i18next-parser": "7.7.0", + "identity-obj-proxy": "3.0.0", + "is-interactive": "2.0.0", + "jest": "29.4.3", + "jest-axe": "7.0.0", + "jest-chain": "1.1.6", + "jest-extended": "3.2.4", + "jest-raw-loader": "1.0.1", + "jest-runner-eslint": "1.1.0", + "jest-styled-components": "7.1.1", + "jest-transformer-mdx": "3.3.0", + "jest-watch-typeahead": "2.2.2", + "json-autotranslate": "1.10.5", + "json-loader": "0.5.7", + "morgan": "1.10.0", + "nodemon": "2.0.20", + "npm-run-all": "4.1.5", + "postcss-flexbugs-fixes": "5.0.2", + "postcss-loader": "7.0.2", + "postcss-preset-env": "8.0.1", + "prettier": "2.8.4", + "raw-loader": "4.0.2", + "remark-autolink-headings": "7.0.1", + "remark-breaks": "3.0.2", + "remark-images": "3.1.0", + "remark-math": "5.1.1", + "remark-slug": "7.0.1", + "remark-toc": "8.0.1", + "rimraf": "4.1.2", + "sass": "1.58.3", + "source-map-loader": "4.0.1", + "style-loader": "3.3.1", + "svgo": "3.0.2", + "ts-essentials": "9.3.0", + "ts-node": "10.9.1", + "ts-toolbelt": "9.6.0", + "typescript": "4.9.5", + "url-loader": "4.1.1", + "utility-types": "3.10.0", + "webpack": "5.75.0", + "webpack-cli": "5.0.1" + }, + "resolutions": { + "@mdx-js/loader": "2.3.0", + "@mdx-js/react": "2.3.0", + "@next/mdx": "13.2.1", + "@react-spring/core": "9.7.0", + "@tanstack/query-core": "4.24.10", + "@tanstack/react-query": "4.24.10", + "@tanstack/react-query-devtools": "4.24.10", + "@typescript-eslint/eslint-plugin": "5.53.0", + "@typescript-eslint/parser": "5.53.0", + "@typescript-eslint/typescript-estree": "5.53.0", + "axios": "1.3.4", + "bootstrap": "5.2.3", + "classnames": "2.3.2", + "core-js": "3.28.0", + "css-loader": "6.7.3", + "eslint": "8.34.0", + "eslint-config-airbnb": "19.0.4", + "eslint-config-airbnb-base": "15.0.0", + "eslint-config-airbnb-typescript": "17.0.0", + "eslint-config-next": "13.2.1", + "eslint-config-prettier": "8.6.0", + "eslint-config-react-app": "7.0.1", + "eslint-formatter-codeframe": "7.32.1", + "eslint-import-resolver-ts": "0.4.2", + "eslint-import-resolver-typescript": "3.5.3", + "eslint-plugin-array-func": "3.1.8", + "eslint-plugin-cflint": "1.0.0", + "eslint-plugin-cypress": "2.12.1", + "eslint-plugin-flowtype": "8.0.3", + "eslint-plugin-import": "2.27.5", + "eslint-plugin-jest": "27.2.1", + "eslint-plugin-jest-dom": "4.0.3", + "eslint-plugin-json": "3.1.0", + "eslint-plugin-jsx-a11y": "6.7.1", + "eslint-plugin-lodash": "7.4.0", + "eslint-plugin-no-loops": "0.3.0", + "eslint-plugin-no-secrets": "0.8.9", + "eslint-plugin-node": "11.1.0", + "eslint-plugin-only-ascii": "0.0.0", + "eslint-plugin-only-warn": "1.1.0", + "eslint-plugin-prettier": "4.2.1", + "eslint-plugin-promise": "6.1.1", + "eslint-plugin-react": "7.32.2", + "eslint-plugin-react-hooks": "4.6.0", + "eslint-plugin-react-perf": "3.3.1", + "eslint-plugin-security": "1.7.1", + "eslint-plugin-sonarjs": "0.18.0", + "eslint-plugin-unicorn": "45.0.2", + "file-loader": "6.2.0", + "fs-extra": "11.1.0", + "i18next": "22.4.10", + "json-loader": "0.5.7", + "lodash-es": "4.17.21", + "luxon": "3.2.1", + "next": "13.2.1", + "papaparse": "5.3.2", + "prettier": "2.8.4", + "raw-loader": "4.0.2", + "react": "18.2.0", + "react-dom": "18.2.0", + "react-error-boundary": "3.1.4", + "react-i18next": "12.1.5", + "reactstrap": "8.10.1", + "recharts": "2.4.3", + "recoil": "0.7.6", + "reflect-metadata": "0.1.13", + "regenerator-runtime": "0.13.11", + "sass": "1.58.3", + "source-map-loader": "4.0.1", + "style-loader": "3.3.1", + "styled-components": "5.3.6", + "ts-node": "10.9.1", + "typescript": "4.9.5", + "url-loader": "4.1.1", + "use-sync-external-store": "1.2.0", + "webpack": "5.75.0", + "webpack-cli": "5.0.1" + } +} diff --git a/web/postcss.config.json b/web/postcss.config.json new file mode 100644 index 00000000..6edf1255 --- /dev/null +++ b/web/postcss.config.json @@ -0,0 +1,16 @@ +{ + "plugins": { + "postcss-flexbugs-fixes": {}, + "postcss-preset-env": { + "stage": 2, + "features": { + "custom-properties": {"preserve": false} + }, + "autoprefixer": { + "remove": false, + "grid": "autoplace", + "flexbox": "no-2009" + } + } + } +} diff --git a/web/public/.well-known/dnt-policy-1.0.txt b/web/public/.well-known/dnt-policy-1.0.txt new file mode 100644 index 00000000..ad946d1f --- /dev/null +++ b/web/public/.well-known/dnt-policy-1.0.txt @@ -0,0 +1,218 @@ +Do Not Track Compliance Policy + +Version 1.0 + +This domain complies with user opt-outs from tracking via the "Do Not Track" +or "DNT" header [http://www.w3.org/TR/tracking-dnt/]. This file will always +be posted via HTTPS at https://example-domain.com/.well-known/dnt-policy.txt +to indicate this fact. + +SCOPE + +This policy document allows an operator of a Fully Qualified Domain Name +("domain") to declare that it respects Do Not Track as a meaningful privacy +opt-out of tracking, so that privacy-protecting software can better determine +whether to block or anonymize communications with this domain. This policy is +intended first and foremost to be posted on domains that publish ads, widgets, +images, scripts and other third-party embedded hypertext (for instance on +widgets.example.com), but it can be posted on any domain, including those users +visit directly (such as www.example.com). The policy may be applied to some +domains used by a company, site, or service, and not to others. Do Not Track +may be sent by any client that uses the HTTP protocol, including websites, +mobile apps, and smart devices like TVs. Do Not Track also works with all +protocols able to read HTTP headers, including SPDY. + +NOTE: This policy contains both Requirements and Exceptions. Where possible +terms are defined in the text, but a few additional definitions are included +at the end. + +REQUIREMENTS + +When this domain receives Web requests from a user who enables DNT by actively +choosing an opt-out setting in their browser or by installing software that is +primarily designed to protect privacy ("DNT User"), we will take the following +measures with respect to those users' data, subject to the Exceptions, also +listed below: + +1. END USER IDENTIFIERS: + + a. If a DNT User has logged in to our service, all user identifiers, such as + unique or nearly unique cookies, "supercookies" and fingerprints are + discarded as soon as the HTTP(S) response is issued. + + Data structures which associate user identifiers with accounts may be + employed to recognize logged in users per Exception 4 below, but may not + be associated with records of the user's activities unless otherwise + excepted. + + b. If a DNT User is not logged in to our service, we will take steps to ensure + that no user identifiers are transmitted to us at all. + +2. LOG RETENTION: + + a. Logs with DNT Users' identifiers removed (but including IP addresses and + User Agent strings) may be retained for a period of 10 days or less, + unless an Exception (below) applies. This period of time balances privacy + concerns with the need to ensure that log processing systems have time to + operate; that operations engineers have time to monitor and fix technical + and performance problems; and that security and data aggregation systems + have time to operate. + + b. These logs will not be used for any other purposes. + +3. OTHER DOMAINS: + + a. If this domain transfers identifiable user data about DNT Users to + contractors, affiliates or other parties, or embeds from or posts data to + other domains, we will either: + + b. ensure that the operators of those domains abide by this policy overall + by posting it at /.well-known/dnt-policy.txt via HTTPS on the domains in + question, + + OR + + ensure that the recipient's policies and practices require the recipient + to respect the policy for our DNT Users' data. + + OR + + obtain a contractual commitment from the recipient to respect this policy + for our DNT Users' data. + + NOTE: if an “Other Domain” does not receive identifiable user information + from the domain because such information has been removed, because the + Other Domain does not log that information, or for some other reason, these + requirements do not apply. + + c. "Identifiable" means any records which are not Anonymized or otherwise + covered by the Exceptions below. + +4. PERIODIC REASSERTION OF COMPLIANCE: + + At least once every 12 months, we will take reasonable steps commensurate + with the size of our organization and the nature of our service to confirm + our ongoing compliance with this document, and we will publicly reassert our + compliance. + +5. USER NOTIFICATION: + + a. If we are required by law to retain or disclose user identifiers, we will + attempt to provide the users with notice (unless we are prohibited or it + would be futile) that a request for their information has been made in + order to give the users an opportunity to object to the retention or + disclosure. + + b. We will attempt to provide this notice by email, if the users have given + us an email address, and by postal mail if the users have provided a + postal address. + + c. If the users do not challenge the disclosure request, we may be legally + required to turn over their information. + + d. We may delay notice if we, in good faith, believe that an emergency + involving danger of death or serious physical injury to any person + requires disclosure without delay of information relating to the + emergency. + +EXCEPTIONS + +Data from DNT Users collected by this domain may be logged or retained only in +the following specific situations: + +1. CONSENT / "OPT BACK IN" + + a. DNT Users are opting out from tracking across the Web. It is possible + that for some feature or functionality, we will need to ask a DNT User to + "opt back in" to be tracked by us across the entire Web. + + b. If we do that, we will take reasonable steps to verify that the users who + select this option have genuinely intended to opt back in to tracking. + One way to do this is by performing scientifically reasonable user + studies with a representative sample of our users, but smaller + organizations can satisfy this requirement by other means. + + c. Where we believe that we have opt back in consent, our server will + send a tracking value status header "Tk: C" as described in section 6.2 + of the W3C Tracking Preference Expression draft: + + http://www.w3.org/TR/tracking-dnt/#tracking-status-value + +2. TRANSACTIONS + + If a DNT User actively and knowingly enters a transaction with our + services (for instance, clicking on a clearly-labeled advertisement, + posting content to a widget, or purchasing an item), we will retain + necessary data for as long as required to perform the transaction. This + may for example include keeping auditing information for clicks on + advertising links; keeping a copy of posted content and the name of the + posting user; keeping server-side session IDs to recognize logged in + users; or keeping a copy of the physical address to which a purchased + item will be shipped. By their nature, some transactions will require data + to be retained indefinitely. + +3. TECHNICAL AND SECURITY LOGGING: + + a. If, during the processing of the initial request (for unique identifiers) + or during the subsequent 10 days (for IP addresses and User Agent strings), + we obtain specific information that causes our employees or systems to + believe that a request is, or is likely to be, part of a security attack, + spam submission, or fraudulent transaction, then logs of those requests + are not subject to this policy. + + b. If we encounter technical problems with our site, then, in rare + circumstances, we may retain logs for longer than 10 days, if that is + necessary to diagnose and fix those problems, but this practice will not be + routinized and we will strive to delete such logs as soon as possible. + +4. AGGREGATION: + + a. We may retain and share anonymized datasets, such as aggregate records of + readership patterns; statistical models of user behavior; graphs of system + variables; data structures to count active users on monthly or yearly + bases; database tables mapping authentication cookies to logged in + accounts; non-unique data structures constructed within browsers for tasks + such as ad frequency capping or conversion tracking; or logs with truncated + and/or encrypted IP addresses and simplified User Agent strings. + + b. "Anonymized" means we have conducted risk mitigation to ensure + that the dataset, plus any additional information that is in our + possession or likely to be available to us, does not allow the + reconstruction of reading habits, online or offline activity of groups of + fewer than 5000 individuals or devices. + + c. If we generate anonymized datasets under this exception we will publicly + document our anonymization methods in sufficient detail to allow outside + experts to evaluate the effectiveness of those methods. + +5. ERRORS: + +From time to time, there may be errors by which user data is temporarily +logged or retained in violation of this policy. If such errors are +inadvertent, rare, and made in good faith, they do not constitute a breach +of this policy. We will delete such data as soon as practicable after we +become aware of any error and take steps to ensure that it is deleted by any +third-party who may have had access to the data. + +ADDITIONAL DEFINITIONS + +"Fully Qualified Domain Name" means a domain name that addresses a computer +connected to the Internet. For instance, example1.com; www.example1.com; +ads.example1.com; and widgets.example2.com are all distinct FQDNs. + +"Supercookie" means any technology other than an HTTP Cookie which can be used +by a server to associate identifiers with the clients that visit it. Examples +of supercookies include Flash LSO cookies, DOM storage, HTML5 storage, or +tricks to store information in caches or etags. + +"Risk mitigation" means an engineering process that evaluates the possibility +and likelihood of various adverse outcomes, considers the available methods of +making those adverse outcomes less likely, and deploys sufficient mitigations +to bring the probability and harm from adverse outcomes below an acceptable +threshold. + +"Reading habits" includes amongst other things lists of visited DNS names, if +those domains pertain to specific topics or activities, but records of visited +DNS names are not reading habits if those domain names serve content of a very +diverse and general nature, thereby revealing minimal information about the +opinions, interests or activities of the user. diff --git a/web/public/browserconfig.xml b/web/public/browserconfig.xml new file mode 100644 index 00000000..55954cd6 --- /dev/null +++ b/web/public/browserconfig.xml @@ -0,0 +1,12 @@ + + + + + + + + + #492c7c + + + diff --git a/web/public/favicon.ico b/web/public/favicon.ico new file mode 100644 index 00000000..e0b09acb Binary files /dev/null and b/web/public/favicon.ico differ diff --git a/web/public/favicon.png b/web/public/favicon.png new file mode 100644 index 00000000..40a6d4fa Binary files /dev/null and b/web/public/favicon.png differ diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 00000000..f291b7be --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/icons/android-chrome-144x144.png b/web/public/icons/android-chrome-144x144.png new file mode 100644 index 00000000..09e6ab4c Binary files /dev/null and b/web/public/icons/android-chrome-144x144.png differ diff --git a/web/public/icons/android-chrome-192x192.png b/web/public/icons/android-chrome-192x192.png new file mode 100644 index 00000000..d2853b28 Binary files /dev/null and b/web/public/icons/android-chrome-192x192.png differ diff --git a/web/public/icons/android-chrome-256x256.png b/web/public/icons/android-chrome-256x256.png new file mode 100644 index 00000000..40a6d4fa Binary files /dev/null and b/web/public/icons/android-chrome-256x256.png differ diff --git a/web/public/icons/android-chrome-36x36.png b/web/public/icons/android-chrome-36x36.png new file mode 100644 index 00000000..735d2144 Binary files /dev/null and b/web/public/icons/android-chrome-36x36.png differ diff --git a/web/public/icons/android-chrome-384x384.png b/web/public/icons/android-chrome-384x384.png new file mode 100644 index 00000000..4dc46eb6 Binary files /dev/null and b/web/public/icons/android-chrome-384x384.png differ diff --git a/web/public/icons/android-chrome-48x48.png b/web/public/icons/android-chrome-48x48.png new file mode 100644 index 00000000..317eabab Binary files /dev/null and b/web/public/icons/android-chrome-48x48.png differ diff --git a/web/public/icons/android-chrome-512x512.png b/web/public/icons/android-chrome-512x512.png new file mode 100644 index 00000000..26fe6794 Binary files /dev/null and b/web/public/icons/android-chrome-512x512.png differ diff --git a/web/public/icons/android-chrome-72x72.png b/web/public/icons/android-chrome-72x72.png new file mode 100644 index 00000000..802d8a52 Binary files /dev/null and b/web/public/icons/android-chrome-72x72.png differ diff --git a/web/public/icons/android-chrome-96x96.png b/web/public/icons/android-chrome-96x96.png new file mode 100644 index 00000000..bac20252 Binary files /dev/null and b/web/public/icons/android-chrome-96x96.png differ diff --git a/web/public/icons/apple-touch-icon-114x114-precomposed.png b/web/public/icons/apple-touch-icon-114x114-precomposed.png new file mode 100644 index 00000000..69cc00b0 Binary files /dev/null and b/web/public/icons/apple-touch-icon-114x114-precomposed.png differ diff --git a/web/public/icons/apple-touch-icon-114x114.png b/web/public/icons/apple-touch-icon-114x114.png new file mode 100644 index 00000000..20db0e71 Binary files /dev/null and b/web/public/icons/apple-touch-icon-114x114.png differ diff --git a/web/public/icons/apple-touch-icon-120x120-precomposed.png b/web/public/icons/apple-touch-icon-120x120-precomposed.png new file mode 100644 index 00000000..9dbb7d9b Binary files /dev/null and b/web/public/icons/apple-touch-icon-120x120-precomposed.png differ diff --git a/web/public/icons/apple-touch-icon-120x120.png b/web/public/icons/apple-touch-icon-120x120.png new file mode 100644 index 00000000..0383b478 Binary files /dev/null and b/web/public/icons/apple-touch-icon-120x120.png differ diff --git a/web/public/icons/apple-touch-icon-144x144-precomposed.png b/web/public/icons/apple-touch-icon-144x144-precomposed.png new file mode 100644 index 00000000..1c7703c5 Binary files /dev/null and b/web/public/icons/apple-touch-icon-144x144-precomposed.png differ diff --git a/web/public/icons/apple-touch-icon-144x144.png b/web/public/icons/apple-touch-icon-144x144.png new file mode 100644 index 00000000..2bfc2868 Binary files /dev/null and b/web/public/icons/apple-touch-icon-144x144.png differ diff --git a/web/public/icons/apple-touch-icon-152x152-precomposed.png b/web/public/icons/apple-touch-icon-152x152-precomposed.png new file mode 100644 index 00000000..9d08065e Binary files /dev/null and b/web/public/icons/apple-touch-icon-152x152-precomposed.png differ diff --git a/web/public/icons/apple-touch-icon-152x152.png b/web/public/icons/apple-touch-icon-152x152.png new file mode 100644 index 00000000..7fbfd37e Binary files /dev/null and b/web/public/icons/apple-touch-icon-152x152.png differ diff --git a/web/public/icons/apple-touch-icon-180x180-precomposed.png b/web/public/icons/apple-touch-icon-180x180-precomposed.png new file mode 100644 index 00000000..06b4de7d Binary files /dev/null and b/web/public/icons/apple-touch-icon-180x180-precomposed.png differ diff --git a/web/public/icons/apple-touch-icon-180x180.png b/web/public/icons/apple-touch-icon-180x180.png new file mode 100644 index 00000000..b7d30675 Binary files /dev/null and b/web/public/icons/apple-touch-icon-180x180.png differ diff --git a/web/public/icons/apple-touch-icon-57x57-precomposed.png b/web/public/icons/apple-touch-icon-57x57-precomposed.png new file mode 100644 index 00000000..18ed37eb Binary files /dev/null and b/web/public/icons/apple-touch-icon-57x57-precomposed.png differ diff --git a/web/public/icons/apple-touch-icon-57x57.png b/web/public/icons/apple-touch-icon-57x57.png new file mode 100644 index 00000000..3d8e5729 Binary files /dev/null and b/web/public/icons/apple-touch-icon-57x57.png differ diff --git a/web/public/icons/apple-touch-icon-60x60-precomposed.png b/web/public/icons/apple-touch-icon-60x60-precomposed.png new file mode 100644 index 00000000..4c22ee2e Binary files /dev/null and b/web/public/icons/apple-touch-icon-60x60-precomposed.png differ diff --git a/web/public/icons/apple-touch-icon-60x60.png b/web/public/icons/apple-touch-icon-60x60.png new file mode 100644 index 00000000..f2eb4279 Binary files /dev/null and b/web/public/icons/apple-touch-icon-60x60.png differ diff --git a/web/public/icons/apple-touch-icon-72x72-precomposed.png b/web/public/icons/apple-touch-icon-72x72-precomposed.png new file mode 100644 index 00000000..d54ebab7 Binary files /dev/null and b/web/public/icons/apple-touch-icon-72x72-precomposed.png differ diff --git a/web/public/icons/apple-touch-icon-72x72.png b/web/public/icons/apple-touch-icon-72x72.png new file mode 100644 index 00000000..4a3f9e1c Binary files /dev/null and b/web/public/icons/apple-touch-icon-72x72.png differ diff --git a/web/public/icons/apple-touch-icon-76x76-precomposed.png b/web/public/icons/apple-touch-icon-76x76-precomposed.png new file mode 100644 index 00000000..45e97753 Binary files /dev/null and b/web/public/icons/apple-touch-icon-76x76-precomposed.png differ diff --git a/web/public/icons/apple-touch-icon-76x76.png b/web/public/icons/apple-touch-icon-76x76.png new file mode 100644 index 00000000..21c67c1f Binary files /dev/null and b/web/public/icons/apple-touch-icon-76x76.png differ diff --git a/web/public/icons/apple-touch-icon-precomposed.png b/web/public/icons/apple-touch-icon-precomposed.png new file mode 100644 index 00000000..06b4de7d Binary files /dev/null and b/web/public/icons/apple-touch-icon-precomposed.png differ diff --git a/web/public/icons/apple-touch-icon.png b/web/public/icons/apple-touch-icon.png new file mode 100644 index 00000000..b7d30675 Binary files /dev/null and b/web/public/icons/apple-touch-icon.png differ diff --git a/web/public/icons/favicon-128x128.png b/web/public/icons/favicon-128x128.png new file mode 100644 index 00000000..e742d1ef Binary files /dev/null and b/web/public/icons/favicon-128x128.png differ diff --git a/web/public/icons/favicon-16x16.png b/web/public/icons/favicon-16x16.png new file mode 100644 index 00000000..16ee6d5a Binary files /dev/null and b/web/public/icons/favicon-16x16.png differ diff --git a/web/public/icons/favicon-196x196.png b/web/public/icons/favicon-196x196.png new file mode 100644 index 00000000..877c8c11 Binary files /dev/null and b/web/public/icons/favicon-196x196.png differ diff --git a/web/public/icons/favicon-32x32.png b/web/public/icons/favicon-32x32.png new file mode 100644 index 00000000..807d4d73 Binary files /dev/null and b/web/public/icons/favicon-32x32.png differ diff --git a/web/public/icons/favicon-96x96.png b/web/public/icons/favicon-96x96.png new file mode 100644 index 00000000..bf7fc46a Binary files /dev/null and b/web/public/icons/favicon-96x96.png differ diff --git a/web/public/icons/mstile-144x144.png b/web/public/icons/mstile-144x144.png new file mode 100644 index 00000000..9afb4031 Binary files /dev/null and b/web/public/icons/mstile-144x144.png differ diff --git a/web/public/icons/mstile-150x150.png b/web/public/icons/mstile-150x150.png new file mode 100644 index 00000000..b685f3e2 Binary files /dev/null and b/web/public/icons/mstile-150x150.png differ diff --git a/web/public/icons/mstile-310x150.png b/web/public/icons/mstile-310x150.png new file mode 100644 index 00000000..8e4d9f67 Binary files /dev/null and b/web/public/icons/mstile-310x150.png differ diff --git a/web/public/icons/mstile-310x310.png b/web/public/icons/mstile-310x310.png new file mode 100644 index 00000000..caf4d18e Binary files /dev/null and b/web/public/icons/mstile-310x310.png differ diff --git a/web/public/icons/mstile-70x70.png b/web/public/icons/mstile-70x70.png new file mode 100644 index 00000000..8e76856a Binary files /dev/null and b/web/public/icons/mstile-70x70.png differ diff --git a/web/public/icons/safari-pinned-tab.svg b/web/public/icons/safari-pinned-tab.svg new file mode 100644 index 00000000..6b06b022 --- /dev/null +++ b/web/public/icons/safari-pinned-tab.svg @@ -0,0 +1,34 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + diff --git a/web/public/manifest.json b/web/public/manifest.json new file mode 100644 index 00000000..47b0ca97 --- /dev/null +++ b/web/public/manifest.json @@ -0,0 +1,22 @@ +{ + "short_name": "Pangraph", + "name": "Pangraph", + "description": "", + "theme_color": "#e5c3c0", + "background_color": "#492c7c", + "start_url": "/?source=pwa", + "display": "standalone", + "scope": "/", + "icons": [ + { + "src": "/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/web/src/assets/images/biozentrum.svg b/web/src/assets/images/biozentrum.svg new file mode 100644 index 00000000..e9314662 --- /dev/null +++ b/web/src/assets/images/biozentrum.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/images/biozentrum_square.svg b/web/src/assets/images/biozentrum_square.svg new file mode 100644 index 00000000..39c88174 --- /dev/null +++ b/web/src/assets/images/biozentrum_square.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/images/logo.svg b/web/src/assets/images/logo.svg new file mode 100644 index 00000000..4f39bcbb --- /dev/null +++ b/web/src/assets/images/logo.svg @@ -0,0 +1 @@ + diff --git a/web/src/assets/images/neherlab.svg b/web/src/assets/images/neherlab.svg new file mode 100644 index 00000000..f7baba77 --- /dev/null +++ b/web/src/assets/images/neherlab.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/images/nextjs.svg b/web/src/assets/images/nextjs.svg new file mode 100644 index 00000000..8f5c90e3 --- /dev/null +++ b/web/src/assets/images/nextjs.svg @@ -0,0 +1 @@ + diff --git a/web/src/assets/images/sib.svg b/web/src/assets/images/sib.svg new file mode 100644 index 00000000..0eeb3b32 --- /dev/null +++ b/web/src/assets/images/sib.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + diff --git a/web/src/assets/images/unibas.svg b/web/src/assets/images/unibas.svg new file mode 100644 index 00000000..6ccf2da6 --- /dev/null +++ b/web/src/assets/images/unibas.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/assets/images/vercel.svg b/web/src/assets/images/vercel.svg new file mode 100644 index 00000000..7d199d32 --- /dev/null +++ b/web/src/assets/images/vercel.svg @@ -0,0 +1 @@ +> diff --git a/web/src/assets/social/social-1200x630.png b/web/src/assets/social/social-1200x630.png new file mode 100644 index 00000000..1fdb8ad1 Binary files /dev/null and b/web/src/assets/social/social-1200x630.png differ diff --git a/web/src/components/Common/Checkbox.tsx b/web/src/components/Common/Checkbox.tsx new file mode 100644 index 00000000..7627208d --- /dev/null +++ b/web/src/components/Common/Checkbox.tsx @@ -0,0 +1,122 @@ +import React, { PropsWithChildren, ReactNode, useCallback, useEffect, useMemo, useRef } from 'react' +import type { StrictOmit } from 'ts-essentials' +import { FormGroup as FormGroupBase, Input, InputProps, Label as LabelBase } from 'reactstrap' +import styled from 'styled-components' +import type { SetterOrUpdater } from 'src/types' + +export interface CheckboxProps extends PropsWithChildren { + title?: string + checked: boolean + setChecked: SetterOrUpdater +} + +export function Checkbox({ title, checked, setChecked, children }: CheckboxProps) { + const onChange = useCallback(() => { + setChecked((checkedPrev) => !checkedPrev) + }, [setChecked]) + + return ( + + + + ) +} + +export interface CheckboxWithTextProps extends Omit { + label: string +} + +export function CheckboxWithText({ label, title, checked, setChecked }: CheckboxWithTextProps) { + const onChange = useCallback(() => { + setChecked((checkedPrev) => !checkedPrev) + }, [setChecked]) + + return ( + + + + ) +} + +export interface CheckboxWithIconProps extends Omit { + label: string + icon: ReactNode +} + +export function CheckboxWithIcon({ title, label, icon, checked, setChecked }: CheckboxWithIconProps) { + return ( + + {icon} + {label} + + ) +} + +export enum CheckboxState { + Checked, + Unchecked, + Indeterminate, +} + +export interface CheckboxIndeterminateProps extends StrictOmit { + state?: CheckboxState + onChange?(state: CheckboxState): void +} + +/** Checkbox with 3 states: checked, unchecked, indeterminate */ +export function CheckboxIndeterminate({ state, onChange, ...restProps }: CheckboxIndeterminateProps) { + const inputRef = useRef(null) + + const handleOnChange = useCallback(() => { + if (state === CheckboxState.Checked) { + return onChange?.(CheckboxState.Unchecked) + } + return onChange?.(CheckboxState.Checked) + }, [onChange, state]) + + useEffect(() => { + if (inputRef?.current) { + inputRef.current.indeterminate = state === CheckboxState.Indeterminate + } + }, [state]) + + const checked = useMemo(() => state === CheckboxState.Checked, [state]) + + return +} + +export interface CheckboxIndeterminateWithTextProps extends Omit { + label: string +} + +export function CheckboxIndeterminateWithText({ label, title, ...restProps }: CheckboxIndeterminateWithTextProps) { + return ( + + + + ) +} + +const FormGroup = styled(FormGroupBase)` + overflow-x: hidden; +` + +const Label = styled(LabelBase)` + white-space: nowrap; + overflow-x: hidden; + text-overflow: ellipsis; + display: block; +` + +const CheckboxText = styled.span` + margin-left: 0.3rem; +` diff --git a/web/src/components/Common/ColoredBox.tsx b/web/src/components/Common/ColoredBox.tsx new file mode 100644 index 00000000..29e77630 --- /dev/null +++ b/web/src/components/Common/ColoredBox.tsx @@ -0,0 +1,11 @@ +import styled from 'styled-components' + +export const ColoredBox = styled.div<{ $color: string; $size: number; $aspect?: number }>` + display: inline-block; + padding: 0; + margin: auto; + background-color: ${(props) => props.$color}; + width: ${(props) => props.$size * (props.$aspect ?? 1)}px; + height: ${(props) => props.$size}px; + border-radius: 2px; +` diff --git a/web/src/components/Common/ColoredCircle.tsx b/web/src/components/Common/ColoredCircle.tsx new file mode 100644 index 00000000..d0b07644 --- /dev/null +++ b/web/src/components/Common/ColoredCircle.tsx @@ -0,0 +1,11 @@ +import styled from 'styled-components' + +export const ColoredCircle = styled.div<{ $color: string; $size: number }>` + display: inline-block; + margin: auto; + margin-right: ${(props) => props.$size / 2}px; + background-color: ${(props) => props.$color}; + width: ${(props) => props.$size}px; + height: ${(props) => props.$size}px; + border-radius: ${(props) => props.$size / 2}px; +` diff --git a/web/src/components/Common/CountryFlag.tsx b/web/src/components/Common/CountryFlag.tsx new file mode 100644 index 00000000..dc801d73 --- /dev/null +++ b/web/src/components/Common/CountryFlag.tsx @@ -0,0 +1,51 @@ +import React, { ReactElement } from 'react' +import styled from 'styled-components' +import iso3311a2 from 'iso-3166-1-alpha-2' +import Flags from 'country-flag-icons/react/3x2' + +export const FlagWrapper = styled.div` + height: calc(1em + 2px); + width: calc(1.33em + 2px); + border: 0.5px solid #aaaa; + display: flex; + + > * { + width: 100%; + height: 100%; + } +` + +export const missingCountryCodes: Record = { + 'Bolivia': 'BO', + 'Bonaire': 'BQ', + 'Brunei': 'BN', + 'Cabo Verde': 'CV', + 'Curacao': 'CW', + 'Democratic Republic of the Congo': 'CD', + 'Eswatini': 'SZ', + 'Iran': 'IR', + 'Kosovo': 'XK', + 'Laos': 'LA', + 'Moldova': 'MD', + 'North Macedonia': 'MK', + 'Republic of the Congo': 'CD', + 'Russia': 'RU', + 'Saint Martin': 'SX', + 'Sint Maarten': 'SX', + 'South Korea': 'KR', + 'Taiwan': 'TW', + 'USA': 'US', + 'Venezuela': 'VE', + 'Vietnam': 'VN', +} + +export function getFlag(country: string): ReactElement | null { + const countryCode = missingCountryCodes[country] ?? iso3311a2.getCode(country) ?? '?' + + const Flag = Flags[countryCode] + if (Flag) { + return + } + + return null +} diff --git a/web/src/components/Common/Dropdown.tsx b/web/src/components/Common/Dropdown.tsx new file mode 100644 index 00000000..6a52a0cf --- /dev/null +++ b/web/src/components/Common/Dropdown.tsx @@ -0,0 +1,99 @@ +import React, { ReactNode, useCallback, useMemo, useState } from 'react' +import styled from 'styled-components' +import { rgba, darken } from 'polished' +import { + Dropdown as DropdownBase, + DropdownToggle as DropdownToggleBase, + DropdownMenu as DropdownMenuBase, + DropdownItem as DropdownItemBase, + DropdownProps as DropdownBaseProps, +} from 'reactstrap' +import { MdArrowDropDown } from 'react-icons/md' + +export interface DropdownEntry { + key: string + value: ReactNode +} + +export interface DropdownProps extends DropdownBaseProps { + entries: DropdownEntry[] + currentEntry: DropdownEntry + setCurrentEntry(key: DropdownEntry): void +} + +export function Dropdown({ currentEntry, setCurrentEntry, entries, ...restProps }: DropdownProps) { + const [dropdownOpen, setDropdownOpen] = useState(false) + const toggle = useCallback(() => setDropdownOpen((prevState) => !prevState), []) + const onClick = useCallback((entry: DropdownEntry) => () => setCurrentEntry(entry), [setCurrentEntry]) + const menuItems = useMemo(() => { + return entries.map((entry) => { + return ( + + {entry.value} + + ) + }) + }, [currentEntry, entries, onClick]) + + return ( + + + {currentEntry.value} + + + {menuItems} + + ) +} + +const DropdownStyled = styled(DropdownBase)` + display: flex; + border: ${(props) => rgba(props.theme.primary, 0.5)} solid 1px; + border-radius: 3px; + box-shadow: ${(props) => props.theme.shadows.lighter}; + + color: ${(props) => props.theme.bodyColor}; + + :hover { + color: ${(props) => props.theme.bodyColor}; + } + + & > a { + // min-width: 200px; + } +` + +const DropdownToggle = styled(DropdownToggleBase)` + display: flex; + color: ${(props) => props.theme.bodyColor}; +` + +const DropdownToggleText = styled.span` + flex: 1; + color: ${(props) => props.theme.bodyColor}; + + :hover { + color: ${(props) => props.theme.bodyColor}; + } +` + +const DropdownCaret = styled(MdArrowDropDown)` + margin: 0 auto; + width: 22px; + height: 22px; +` + +const DropdownMenu = styled(DropdownMenuBase)` + color: ${(props) => props.theme.bodyColor}; + background-color: ${(props) => props.theme.bodyBg}; + box-shadow: ${(props) => props.theme.shadows.blurredMedium}; + max-height: 70vh; + overflow-y: scroll; +` + +const DropdownItem = styled(DropdownItemBase)<{ active: boolean }>` + :hover { + color: ${({ active, theme }) => (active ? theme.white : theme.bodyColor)}; + background: ${({ active, theme }) => (active ? darken(0.025)(theme.primary) : theme.gray200)}; + } +` diff --git a/web/src/components/Common/DropdownWithSearch.tsx b/web/src/components/Common/DropdownWithSearch.tsx new file mode 100644 index 00000000..be6e6d1b --- /dev/null +++ b/web/src/components/Common/DropdownWithSearch.tsx @@ -0,0 +1,55 @@ +import React, { useCallback } from 'react' +import Select from 'react-select' +import type { ActionMeta, OnChangeValue } from 'react-select/dist/declarations/src/types' +import type { StateManagerProps } from 'react-select/dist/declarations/src/useStateManager' + +export type IsMultiValue = false + +export interface DropdownWithSearchProps extends StateManagerProps, IsMultiValue> { + defaultOption?: DropdownOption + onValueChange?(value: string): void + onOptionChange?(option: DropdownOption): void +} + +export function DropdownWithSearch({ + options, + defaultOption, + value, + onOptionChange, + onValueChange, + ...restProps +}: DropdownWithSearchProps) { + const handleChange = useCallback( + (option: OnChangeValue, IsMultiValue>, _actionMeta: ActionMeta>) => { + if (option) { + onValueChange?.(option.value) + onOptionChange?.(option) + } + }, + [onOptionChange, onValueChange], + ) + + return ( +