diff --git a/.commitlintrc.json b/.commitlintrc.json index 5c5e64d..e086b2b 100644 --- a/.commitlintrc.json +++ b/.commitlintrc.json @@ -1,11 +1,7 @@ { "extends": ["@commitlint/config-angular"], "rules": { - "subject-case": [ - 2, - "always", - ["sentence-case", "start-case", "pascal-case", "upper-case", "lower-case"] - ], + "header-max-length": [1, "always", 72], "type-enum": [ 2, "always", diff --git a/.eslintignore b/.eslintignore index 8fd730b..0e66b87 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,4 @@ /node_modules /coverage /public +/src/utils/shadcn.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 0000000..e3d74c2 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,82 @@ +module.exports = { + parser: '@typescript-eslint/parser', + settings: { + react: { + version: 'detect', + }, + 'import/parsers': { + '@typescript-eslint/parser': ['.ts', '.tsx'], + }, + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + }, + }, + }, + env: { + browser: true, + node: true, + es6: true, + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 'esnext', + }, + plugins: ['@typescript-eslint', 'import'], + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:import/errors', + 'plugin:import/warnings', + 'plugin:import/typescript', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'plugin:prettier/recommended', + ], + rules: { + 'no-debugger': 'warn', + 'react/no-unknown-property': ['error', { ignore: ['css'] }], + 'react/prop-types': 'off', + '@typescript-eslint/ban-ts-comment': 'off', + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-unused-vars': 'warn', + 'import/no-named-as-default-member': 'off', + 'import/order': [ + 'error', + { + groups: [ + 'builtin', + 'external', + 'internal', + 'unknown', + 'parent', + 'sibling', + 'index', + 'object', + 'type', + ], + 'newlines-between': 'always', + pathGroups: [ + { + pattern: '@/**', + group: 'internal', + position: 'before', + }, + { + pattern: 'react*', + group: 'external', + position: 'before', + }, + ], + pathGroupsExcludedImportTypes: ['builtin'], + distinctGroup: false, + alphabetize: { + order: 'asc', + caseInsensitive: true, + }, + }, + ], + }, +} diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 0b44f63..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,36 +0,0 @@ -module.exports = { - parser: '@typescript-eslint/parser', - settings: { - react: { - version: 'detect', - }, - }, - env: { - browser: true, - node: true, - es6: true, - }, - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - ecmaVersion: 'esnext', - }, - plugins: ['@typescript-eslint', 'import'], - extends: [ - 'eslint:recommended', - 'plugin:@typescript-eslint/recommended', - 'plugin:import/errors', - 'plugin:import/warnings', - 'plugin:import/typescript', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - 'plugin:prettier/recommended', - ], - rules: { - 'react/prop-types': 'off', - '@typescript-eslint/ban-ts-comment': 'off', - '@typescript-eslint/no-extra-semi': 'off', - '@typescript-eslint/no-unused-vars': 'off', - }, -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf28d76..29f7031 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - node-version: [14] + node-version: [18] steps: - uses: actions/checkout@v1 @@ -37,13 +37,14 @@ jobs: - name: yarn install, build run: | yarn install + yarn build - name: test, report coverage run: | yarn verify-translation - yarn test:coverage + yarn test - - uses: codecov/codecov-action@v1 - if: success() && matrix.os == 'ubuntu-latest' - with: - token: ${{ secrets.CODECOV_TOKEN }} +# - uses: codecov/codecov-action@v1 +# if: success() && matrix.os == 'ubuntu-latest' +# with: +# token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 573c344..84ae907 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - node-version: [14] + node-version: [18] steps: - uses: actions/checkout@v1 diff --git a/.gitignore b/.gitignore index ba00985..0b60358 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,7 @@ # production /build -/build.tar.gz -/yasd.tar.gz +/*.tar.gz # misc .DS_Store diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..3c03207 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +18 diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 8351c19..0000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -14 diff --git a/.prettierrc.js b/.prettierrc.cjs similarity index 100% rename from .prettierrc.js rename to .prettierrc.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6164d8c..42304bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,133 @@ +# [2.0.0-beta.10](https://github.com/geekdada/yasd/compare/v2.0.0-beta.9...v2.0.0-beta.10) (2023-08-28) + + +### Features + +* validate profiles ([e0a2bcb](https://github.com/geekdada/yasd/commit/e0a2bcb75c1cfcc478977dac072e426991aa9fd6)) + + + +# [2.0.0-beta.9](https://github.com/geekdada/yasd/compare/v2.0.0-beta.8...v2.0.0-beta.9) (2023-08-27) + + +### Features + +* manage profiles ([d035462](https://github.com/geekdada/yasd/commit/d0354627de285e1aa5d915b5f0e394396d935fed)) + + + +# [2.0.0-beta.8](https://github.com/geekdada/yasd/compare/v2.0.0-beta.7...v2.0.0-beta.8) (2023-08-24) + + +### Bug Fixes + +* known bugs ([5ff78f7](https://github.com/geekdada/yasd/commit/5ff78f7b8e718b895110581ee66cb4ec34bf6466)) + + +### Features + +* safe area handling ([c367084](https://github.com/geekdada/yasd/commit/c3670847469da77c24bc7a69a3401330a65ce9ab)) + + + +# [2.0.0-beta.7](https://github.com/geekdada/yasd/compare/v2.0.0-beta.6...v2.0.0-beta.7) (2023-08-23) + + +### Features + +* add sorting to request list ([a3bf7ea](https://github.com/geekdada/yasd/commit/a3bf7ead40d9177f7d0479597ec0e7c940bc6b97)) + + + +# [2.0.0-beta.6](https://github.com/geekdada/yasd/compare/v2.0.0-beta.5...v2.0.0-beta.6) (2023-08-17) + + +### Features + +* improve ui ([b5b8b83](https://github.com/geekdada/yasd/commit/b5b8b83a42d394b9c52fbeda5ddafe15ca908516)) + + + +# [2.0.0-beta.5](https://github.com/geekdada/yasd/compare/v2.0.0-beta.4...v2.0.0-beta.5) (2023-08-16) + + +### Features + +* address feedback ([4dea7a7](https://github.com/geekdada/yasd/commit/4dea7a731c9918c3fe2069296ac65d380082f2f2)) + + + +# [2.0.0-beta.4](https://github.com/geekdada/yasd/compare/v2.0.0-beta.3...v2.0.0-beta.4) (2023-08-15) + + +### Bug Fixes + +* navigate to home when run on surge build ([8aad576](https://github.com/geekdada/yasd/commit/8aad576e0912e4bebcb2ff413aa91305fa2b9e6d)) +* remove unused dep ([b7b05ef](https://github.com/geekdada/yasd/commit/b7b05efc78ed9fae4d59323e3259d6e477779e65)) + + + +# [2.0.0-beta.3](https://github.com/geekdada/yasd/compare/v2.0.0-beta.2...v2.0.0-beta.3) (2023-08-15) + + +### Bug Fixes + +* platform property ([8531464](https://github.com/geekdada/yasd/commit/8531464f8a2d38b8e5988bb9ddf93bb649caf5a5)) +* profile loading incorrectly ([9276ecb](https://github.com/geekdada/yasd/commit/9276ecbd738044bfe294dd06ab4ad7713f5c40ad)) + + +### Features + +* add a dialog to install certificate ([91ce682](https://github.com/geekdada/yasd/commit/91ce682c1a42b4186f3b566e589f79f0df0b63ca)) + + + +# [2.0.0-beta.2](https://github.com/geekdada/yasd/compare/v2.0.0-beta.1...v2.0.0-beta.2) (2023-08-14) + + +### Bug Fixes + +* height issue ([b579a3f](https://github.com/geekdada/yasd/commit/b579a3faa678cee17f47a56929066d5f840bc07c)) + + + +# [2.0.0-beta.1](https://github.com/geekdada/yasd/compare/v2.0.0-beta.0...v2.0.0-beta.1) (2023-08-14) + + +### Features + +* address feedback issue ([1e4cfc0](https://github.com/geekdada/yasd/commit/1e4cfc0df9cc788b7bb1b1554760718581ddf161)) +* improve style ([fe00f55](https://github.com/geekdada/yasd/commit/fe00f55faf74bea9b89faab44602065ca1888ac0)) + + + +# [2.0.0-beta.0](https://github.com/geekdada/yasd/compare/v1.1.2...v2.0.0-beta.0) (2023-08-13) + + +### Bug Fixes + +* eslint issues ([89f0cf3](https://github.com/geekdada/yasd/commit/89f0cf300e5a9146cae86548933e1bb6bf99f981)) +* theme color ([2a489e8](https://github.com/geekdada/yasd/commit/2a489e80fe33408f6d8052afb93248a2297b0d69)) +* upgrade issues ([6eb6edf](https://github.com/geekdada/yasd/commit/6eb6edfcc459d1c4ed7000d848330f3affff9ede)) + + +### Features + +* clear traffic and profile on landing page ([8ca3f51](https://github.com/geekdada/yasd/commit/8ca3f5135aec6a81e0e517e8375ee8fc0a7cb99e)) +* give the home page a new look ([7482975](https://github.com/geekdada/yasd/commit/74829758d45c755d7569afda40d1bbcced6b4354)) +* give the policies page a new look ([830fa0e](https://github.com/geekdada/yasd/commit/830fa0ed11fff2e47b54c94f48ba8286818645ed)) +* improve history and profile management ([032fa5d](https://github.com/geekdada/yasd/commit/032fa5d50ae915d715023c566c56278964720506)) +* improve landing page ([5a6c438](https://github.com/geekdada/yasd/commit/5a6c438f0af096d05f854e7285f740dc2eaf6dad)) +* improve style ([72c9ee2](https://github.com/geekdada/yasd/commit/72c9ee2188000ad25a8eae3f749a9b7763a1befb)) +* improve the requests page ([e7cab51](https://github.com/geekdada/yasd/commit/e7cab514416be661d745d28723f72102742c5e89)) +* improve ui ([a88bf49](https://github.com/geekdada/yasd/commit/a88bf49e43b60d9aefad98ac13b786d9bdb95569)) +* improve ui ([2f79a4c](https://github.com/geekdada/yasd/commit/2f79a4c106e6df1f2a8269a1d08f53d3afc4cd98)) +* improve ui ([06a7e00](https://github.com/geekdada/yasd/commit/06a7e00b04e9b5321807460b3338bd68b9713be6)) +* include sashimi ([f2f3ded](https://github.com/geekdada/yasd/commit/f2f3dedff161bd7af08055622101b29add1a0b44)) +* use traffic context to store traffic related data ([ed833c0](https://github.com/geekdada/yasd/commit/ed833c0caa97ed092882c5d7a57c4a45f165747f)) + + + ## [1.1.2](https://github.com/geekdada/yasd/compare/v1.1.1...v1.1.2) (2021-11-03) diff --git a/README.md b/README.md index e7992fa..f374e11 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,12 @@ ![Github Actions][github-actions-image] [![Test coverage][codecov-image]][codecov-url] -[![David deps][david-image]][david-url] [![Known Vulnerabilities][snyk-image]][snyk-url] [codecov-image]: https://codecov.io/gh/geekdada/yasd/branch/master/graph/badge.svg [codecov-url]: https://codecov.io/gh/geekdada/yasd -[david-image]: https://img.shields.io/david/geekdada/yasd.svg?style=flat-square -[david-url]: https://david-dm.org/geekdada/yasd -[snyk-image]: https://snyk.io/test/github/geekdada/yasd/badge.svg?targetFile=package.json -[snyk-url]: https://snyk.io/test/github/geekdada/yasd?targetFile=package.json +[snyk-image]: https://snyk.io/test/github/geekdada/yasd/badge.svg +[snyk-url]: https://snyk.io/test/github/geekdada/yasd [github-actions-image]: https://github.com/geekdada/yasd/workflows/Node%20CI/badge.svg [中文](/README_zh-CN.md) | [English](/README.md) diff --git a/README_zh-CN.md b/README_zh-CN.md index 09100a6..ef9a7e4 100644 --- a/README_zh-CN.md +++ b/README_zh-CN.md @@ -8,15 +8,12 @@ ![Github Actions][github-actions-image] [![Test coverage][codecov-image]][codecov-url] -[![David deps][david-image]][david-url] [![Known Vulnerabilities][snyk-image]][snyk-url] [codecov-image]: https://codecov.io/gh/geekdada/yasd/branch/master/graph/badge.svg [codecov-url]: https://codecov.io/gh/geekdada/yasd -[david-image]: https://img.shields.io/david/geekdada/yasd.svg?style=flat-square -[david-url]: https://david-dm.org/geekdada/yasd -[snyk-image]: https://snyk.io/test/github/geekdada/yasd/badge.svg?targetFile=package.json -[snyk-url]: https://snyk.io/test/github/geekdada/yasd?targetFile=package.json +[snyk-image]: https://snyk.io/test/github/geekdada/yasd/badge.svg +[snyk-url]: https://snyk.io/test/github/geekdada/yasd [github-actions-image]: https://github.com/geekdada/yasd/workflows/Node%20CI/badge.svg [中文](/README_zh-CN.md) | [English](/README.md) diff --git a/bump.config.ts b/bump.config.ts new file mode 100644 index 0000000..156e990 --- /dev/null +++ b/bump.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'bumpp' + +export default defineConfig({ + execute: 'npm run changelog', + all: true, +}) diff --git a/components.json b/components.json new file mode 100644 index 0000000..9b23164 --- /dev/null +++ b/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.cjs", + "css": "src/styles/shadcn.css", + "baseColor": "zinc", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/utils/shadcn" + } +} diff --git a/craco.config.cjs b/craco.config.cjs new file mode 100644 index 0000000..314bd97 --- /dev/null +++ b/craco.config.cjs @@ -0,0 +1,44 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const pkg = require('./package.json') + +process.env.REACT_APP_VERSION = pkg.version + +/** + * @type {import('@craco/types').CracoConfig} + */ +const config = { + eslint: { + enable: true, + mode: 'file', + }, + style: { + postcss: { + mode: 'file', + }, + }, + webpack: { + alias: { + '@': `${__dirname}/src`, + }, + configure: (webpackConfig) => { + if ( + process.env.NODE_ENV === 'production' && + process.env.REACT_APP_RUN_IN_SURGE === 'true' + ) { + webpackConfig.devtool = false + } + return webpackConfig + }, + }, + babel: { + plugins: ['babel-plugin-macros', '@emotion/babel-plugin'], + presets: [ + [ + '@babel/preset-react', + { runtime: 'automatic', importSource: '@emotion/react' }, + ], + ], + }, +} + +module.exports = config diff --git a/craco.config.js b/craco.config.js deleted file mode 100644 index 688ee5c..0000000 --- a/craco.config.js +++ /dev/null @@ -1,19 +0,0 @@ -'use strict' - -// eslint-disable-next-line @typescript-eslint/no-var-requires -const pkg = require('./package.json') - -process.env.REACT_APP_VERSION = pkg.version - -module.exports = { - plugins: [], - style: { - postcss: { - plugins: [ - require('postcss-import'), - require('tailwindcss'), - require('autoprefixer'), - ], - }, - }, -} diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..8b03e27 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,28 @@ +import { pathsToModuleNameMapper } from 'ts-jest' + +import tsConfig from './tsconfig.json' assert { type: 'json' } + +/** @type {import('ts-jest').JestConfigWithTsJest} */ +const config = { + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'jsdom', + testMatch: ['/src/**/*.spec.{ts,tsx}'], + roots: [''], + modulePaths: [tsConfig.compilerOptions.baseUrl], + moduleNameMapper: pathsToModuleNameMapper(tsConfig.compilerOptions.paths, { + useESM: true, + prefix: '/', + }), + transform: { + '^.+\\.[tj]sx?$': [ + 'ts-jest', + { + useESM: true, + }, + ], + }, + setupFilesAfterEnv: ['/src/setupTests.ts'], + extensionsToTreatAsEsm: ['.ts', '.tsx'], +} + +export default config diff --git a/package.json b/package.json index da4f22f..25d6b23 100644 --- a/package.json +++ b/package.json @@ -1,128 +1,221 @@ { "name": "yasd", - "version": "1.1.2", + "version": "2.0.0-beta.10", "private": true, + "type": "module", "license": "MIT", "scripts": { - "start": "craco start", + "start": "BROWSER=none craco start", "build": "zx scripts/build.mjs release-vercel", "build:release": "zx scripts/build.mjs release-ci", "build:surge": "zx scripts/build.mjs surge", - "test": "craco test --watchAll=false", - "test:watch": "craco test", - "test:coverage": "craco test --coverage --runInBand --watchAll=false", - "test:lint": "eslint . --ext .mjs,.js,.jsx,.ts,.tsx", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --config jest.config.js", + "test:watch": "yarn test --watch", + "test:coverage": "yarn test --coverage --runInBand", + "test:types": "tsc --noEmit", + "test:types:watch": "tsc --noEmit --watch", + "lint": "eslint . --ext .mjs,.js,.jsx,.ts,.tsx", + "lint:fix": "yarn lint --fix", "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s -r 0", - "pub": "np --no-publish", + "release": "bumpp", + "release:beta": "bumpp --preid beta --no-push", "version": "npm run changelog && git add .", - "snyk-protect": "snyk protect", + "snyk-protect": "snyk-protect", "prepare": "npm run snyk-protect && husky install", - "verify-translation": "zx scripts/verify-translations.mjs" + "verify-translation": "zx scripts/verify-translations.mjs", + "shadcn": "shadcn-ui", + "shadcn:add": "zx scripts/shadcn-add.mjs" }, "dependencies": { - "@chakra-ui/icons": "^1.0.16", - "@commitlint/cli": "^12.0.1", - "@commitlint/config-angular": "^12.0.1", - "@craco/craco": "^6.1.1", - "@emotion/cache": "^10", - "@emotion/core": "^10.0.35", - "@emotion/styled": "^10.0.27", - "@loadable/component": "^5.14.1", - "@sumup/circuit-ui": "^2.2.2", - "@sumup/collector": "^1.0.1", - "@sumup/design-tokens": "^2.0.1", - "@sumup/icons": "^1.2.0", - "@sumup/intl": "^1.1.3", - "@testing-library/jest-dom": "^5.11.5", - "@testing-library/react": "^11.1.0", - "@testing-library/react-hooks": "^3.4.2", - "@testing-library/user-event": "^12.1.10", - "@types/bluebird": "^3.5.32", + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@babel/preset-react": "^7.22.5", + "@codemirror/lang-javascript": "^6.1.9", + "@codemirror/view": "^6.16.0", + "@commitlint/cli": "^17.6.7", + "@commitlint/config-angular": "^17.6.7", + "@craco/craco": "^7.1.0", + "@craco/types": "^7.1.0", + "@emotion/babel-plugin": "^11.11.0", + "@emotion/css": "^11.11.2", + "@emotion/eslint-plugin": "^11.11.0", + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@hookform/resolvers": "^3.2.0", + "@radix-ui/react-alert-dialog": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.4", + "@radix-ui/react-dropdown-menu": "^2.0.5", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.6", + "@radix-ui/react-select": "^1.2.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-toggle": "^1.0.3", + "@reduxjs/toolkit": "^1.9.5", + "@snyk/protect": "^1.1200.0", + "@sumup/icons": "^2.30.1", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^14.0.0", + "@testing-library/react-hooks": "^8.0.1", + "@testing-library/user-event": "^14.4.3", + "@types/bluebird": "^3.5.38", "@types/bytes": "^3.1.0", - "@types/chart.js": "^2.9.27", - "@types/enzyme": "^3.10.7", - "@types/enzyme-adapter-react-16": "^1.0.6", - "@types/fs-extra": "^9.0.11", - "@types/jest": "^26.0.15", + "@types/fs-extra": "^11.0.1", + "@types/jest": "^29.5.3", "@types/loadable__component": "^5.13.0", - "@types/lodash-es": "^4.17.3", - "@types/node": "^14.0.0", - "@types/react": "^17.0.3", + "@types/lodash-es": "^4.17.8", + "@types/node": "^18.0.0", + "@types/path-browserify": "^1.0.0", + "@types/react": "^18", "@types/react-collapse": "^5.0.0", - "@types/react-dom": "^17.0.3", + "@types/react-dom": "^18.2.7", + "@types/react-helmet": "^6.1.6", "@types/react-router-dom": "^5.1.5", - "@types/react-tabs": "^2.3.2", - "@types/react-virtualized": "^9.21.10", - "@types/semver": "^7.3.4", - "@types/uuid": "^8.3.0", - "@typescript-eslint/eslint-plugin": "^5.2.0", - "@typescript-eslint/parser": "^5.2.0", - "autoprefixer": "^9.8.6", + "@types/react-tabs": "^5.0.5", + "@types/react-virtualized": "^9.21.22", + "@types/semver": "^7.5.0", + "@types/uuid": "^9.0.2", + "@typescript-eslint/eslint-plugin": "^6.2.1", + "@typescript-eslint/parser": "^6.2.1", + "@uiw/codemirror-theme-material": "^4.21.9", + "@uiw/react-codemirror": "^4.21.9", + "autoprefixer": "^10.4.14", "await-to-js": "^3.0.0", - "axios": "^0.21.0", + "axios": "^1.4.0", + "babel-plugin-module-resolver": "^5.0.0", + "babel-plugin-twin": "^1.1.0", "bluebird": "^3.7.2", + "bumpp": "^9.1.1", "bytes": "^3.1.0", - "chart.js": "^2.9.4", - "clsx": "^1.1.1", - "codemirror": "^5.58.2", - "dayjs": "^1.9.4", - "emotion-theming": "^10.0.27", - "enzyme": "^3.11.0", - "enzyme-adapter-react-16": "^1.15.5", - "enzyme-to-json": "^3.6.1", - "eslint": "^7.32.0", - "eslint-config-prettier": "^8.1.0", - "eslint-plugin-import": "^2.22.1", - "eslint-plugin-prettier": "^4.0.0", - "eslint-plugin-react": "^7.21.5", + "chart.js": "^4.3.3", + "chartjs-adapter-dayjs-4": "^1.0.4", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "codemirror": "^6", + "cross-env": "^7.0.3", + "dayjs": "^1.11.9", + "emotion-theming": "^11.0.0", + "eslint": "^8.46.0", + "eslint-config-prettier": "^8.10.0", + "eslint-import-resolver-typescript": "^3.5.5", + "eslint-plugin-import": "^2.28.0", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-react": "^7.33.1", "eslint-plugin-react-hooks": "^4.2.0", - "framer-motion": "^4", - "fs-extra": "^10.0.0", - "husky": "^5.2.0", - "i18next": "^20.3.1", - "i18next-chained-backend": "^3.0.2", - "i18next-http-backend": "^1.2.6", - "i18next-resources-to-backend": "^1.0.0", + "framer-motion": "^10.15.0", + "fs-extra": "^11.1.1", + "fuse.js": "^6.6.2", + "husky": "^8.0.3", + "i18next": "^23.4.1", + "i18next-chained-backend": "^4.4.0", + "i18next-http-backend": "^2.2.1", + "i18next-resources-to-backend": "^1.1.4", "identity-obj-proxy": "^3.0.0", - "is-ip": "^3.1.0", - "lint-staged": "^10.4.0", + "is-ip": "^5.0.1", + "jest": "^29.6.2", + "jest-environment-jsdom": "^29.6.2", + "lint-staged": "^13.2.3", "lodash-es": "^4.17.15", - "modern-normalize": "^1.0.0", - "node-sass": "^6", - "np": "^7.4.0", + "lucide-react": "^0.263.1", + "node-sass": "^9.0.0", "npm-debug-log-cleaner": "^1.0.3", - "postcss": "^7", - "postcss-import": "^12.0.1", - "prettier": "^2.2.1", + "path-browserify": "^1.0.1", + "postcss": "^8.4.27", + "postcss-import": "^15.1.0", + "prettier": "^3.0.1", "prop-types": "^15.7.2", - "raw.macro": "^0.4.2", "re-resizable": "^6.7.0", - "react": "^17.0.1", + "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-codemirror2": "^7.2.1", "react-collapse": "^5.0.1", - "react-dom": "^17.0.1", - "react-ga": "^3.1.2", - "react-hook-form": "^7.8.4", - "react-i18next": "^11.10.0", - "react-router-dom": "^5.2.0", - "react-scripts": "^4.0.3", + "react-dom": "^18.2.0", + "react-helmet-async": "^1.3.0", + "react-hook-form": "^7.45.4", + "react-hot-toast": "^2.4.1", + "react-i18next": "^13.0.3", + "react-redux": "^8.1.2", + "react-router-dom": "^6.14.2", + "react-scripts": "^5.0.1", "react-scroll-to": "^3.0.0-beta.6", - "react-tabs": "^3.1.1", - "react-toastify": "^7.0.3", - "react-virtualized": "^9.22.2", - "rimraf": "^3.0.2", - "semver": "^7.3.2", + "react-tabs": "^6.0.2", + "react-virtualized": "^9.22.5", + "react-wrap-balancer": "^1.0.0", + "rimraf": "^5.0.1", + "semver": "^7.5.4", + "shadcn-ui": "^0.3.0", "smoothscroll-polyfill": "^0.4.4", - "snyk": "^1.511.0", "store2": "^2.12.0", - "swr": "^0.5.4", - "tailwindcss": "^1.9.6", - "ts-jest": "^26.4.2", - "twin.macro": "^1.11.1", - "typescript": "^4.0.3", + "swr": "^2.2.0", + "tailwind-merge": "^1.14.0", + "tailwindcss": "^3.3.3", + "tailwindcss-animate": "^1.0.6", + "ts-jest": "^29.1.1", + "twin.macro": "^3.4.0", + "typescript": "^5.1.6", "use-is-in-viewport": "^1.0.9", - "uuid": "^8.3.0", - "zx": "~1.14.0" + "uuid": "^9.0.0", + "zod": "^3.21.4", + "zx": "~7.2.3" + }, + "resolutions": { + "react-virtualized/**/@types/react": "^18", + "array-buffer-byte-length": "npm:@nolyfill/array-buffer-byte-length@latest", + "array-includes": "npm:@nolyfill/array-includes@latest", + "array.prototype.findlastindex": "npm:@nolyfill/array.prototype.findlastindex@latest", + "array.prototype.flat": "npm:@nolyfill/array.prototype.flat@latest", + "array.prototype.flatmap": "npm:@nolyfill/array.prototype.flatmap@latest", + "array.prototype.reduce": "npm:@nolyfill/array.prototype.reduce@latest", + "array.prototype.tosorted": "npm:@nolyfill/array.prototype.tosorted@latest", + "arraybuffer.prototype.slice": "npm:@nolyfill/arraybuffer.prototype.slice@latest", + "available-typed-arrays": "npm:@nolyfill/available-typed-arrays@latest", + "deep-equal": "npm:@nolyfill/deep-equal@latest", + "define-properties": "npm:@nolyfill/define-properties@latest", + "es-set-tostringtag": "npm:@nolyfill/es-set-tostringtag@latest", + "function-bind": "npm:@nolyfill/function-bind@latest", + "function.prototype.name": "npm:@nolyfill/function.prototype.name@latest", + "get-symbol-description": "npm:@nolyfill/get-symbol-description@latest", + "globalthis": "npm:@nolyfill/globalthis@latest", + "gopd": "npm:@nolyfill/gopd@latest", + "harmony-reflect": "npm:@nolyfill/harmony-reflect@latest", + "has": "npm:@nolyfill/has@latest", + "has-property-descriptors": "npm:@nolyfill/has-property-descriptors@latest", + "has-proto": "npm:@nolyfill/has-proto@latest", + "has-symbols": "npm:@nolyfill/has-symbols@latest", + "has-tostringtag": "npm:@nolyfill/has-tostringtag@latest", + "is-arguments": "npm:@nolyfill/is-arguments@latest", + "is-array-buffer": "npm:@nolyfill/is-array-buffer@latest", + "is-date-object": "npm:@nolyfill/is-date-object@latest", + "is-generator-function": "npm:@nolyfill/is-generator-function@latest", + "is-regex": "npm:@nolyfill/is-regex@latest", + "is-shared-array-buffer": "npm:@nolyfill/is-shared-array-buffer@latest", + "is-string": "npm:@nolyfill/is-string@latest", + "is-symbol": "npm:@nolyfill/is-symbol@latest", + "is-weakref": "npm:@nolyfill/is-weakref@latest", + "object-is": "npm:@nolyfill/object-is@latest", + "object-keys": "npm:@nolyfill/object-keys@latest", + "object.assign": "npm:@nolyfill/object.assign@latest", + "object.entries": "npm:@nolyfill/object.entries@latest", + "object.fromentries": "npm:@nolyfill/object.fromentries@latest", + "object.getownpropertydescriptors": "npm:@nolyfill/object.getownpropertydescriptors@latest", + "object.groupby": "npm:@nolyfill/object.groupby@latest", + "object.hasown": "npm:@nolyfill/object.hasown@latest", + "object.values": "npm:@nolyfill/object.values@latest", + "regexp.prototype.flags": "npm:@nolyfill/regexp.prototype.flags@latest", + "safe-array-concat": "npm:@nolyfill/safe-array-concat@latest", + "safe-regex-test": "npm:@nolyfill/safe-regex-test@latest", + "string.prototype.matchall": "npm:@nolyfill/string.prototype.matchall@latest", + "string.prototype.trim": "npm:@nolyfill/string.prototype.trim@latest", + "string.prototype.trimend": "npm:@nolyfill/string.prototype.trimend@latest", + "string.prototype.trimstart": "npm:@nolyfill/string.prototype.trimstart@latest", + "typed-array-buffer": "npm:@nolyfill/typed-array-buffer@latest", + "typed-array-byte-length": "npm:@nolyfill/typed-array-byte-length@latest", + "typed-array-byte-offset": "npm:@nolyfill/typed-array-byte-offset@latest", + "typed-array-length": "npm:@nolyfill/typed-array-length@latest", + "unbox-primitive": "npm:@nolyfill/unbox-primitive@latest", + "which-boxed-primitive": "npm:@nolyfill/which-boxed-primitive@latest", + "which-typed-array": "npm:@nolyfill/which-typed-array@latest" }, "browserslist": { "production": [ @@ -135,20 +228,16 @@ "last 1 safari version" ] }, - "jest": { - "transformIgnorePatterns": [ - "/node_modules/(?!lodash-es)", - "^.+\\.module\\.(css|sass|scss)$" - ], - "moduleNameMapper": { - "\\.(css|less|scss|sass)$": "identity-obj-proxy" + "babelMacros": { + "twin": { + "preset": "emotion" } }, "lint-staged": { "*.{js,jsx,ts,tsx}": "eslint --ext .mjs,.js,.jsx,.ts,.tsx" }, "engines": { - "node": ">=14" + "node": ">=18.0.0" }, "snyk": true } diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 0000000..c9dbc3a --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,7 @@ +module.exports = { + plugins: [ + require('postcss-import'), + require('tailwindcss'), + require('autoprefixer'), + ], +} diff --git a/public/index.html b/public/index.html index c107795..699a74f 100644 --- a/public/index.html +++ b/public/index.html @@ -3,7 +3,6 @@ - { @@ -15,19 +17,21 @@ await (async () => { await clean() console.info('🚧 Build artifact') + // Treating warnings as errors because process.env.CI = true. + process.env.CI = 'false' + switch (target) { case 'release-vercel': process.env.NODE_ENV = 'production' process.env.REACT_APP_USE_SW = 'true' await $`craco build` + await insertSashimiScript() break case 'release-ci': process.env.NODE_ENV = 'production' - process.env.REACT_APP_SHOW_AD = 'true' process.env.REACT_APP_HASH_ROUTER = 'true' - process.env.REACT_APP_USE_SW = 'true' process.env.PUBLIC_URL = getUrlPathPrefix() await $`craco build` await changeManifest({ @@ -60,7 +64,7 @@ await (async () => { process.env.PUBLIC_URL = getUrlPathPrefix() await $`craco build` - if ('REACT_APP_HASH_ROUTER' in process.env) { + if (process.env.REACT_APP_HASH_ROUTER === 'true') { await changeManifest({ start_url: `${getUrlPathPrefix()}/#/home`, }) @@ -81,6 +85,15 @@ async function changeManifest(obj = {}) { ) } +async function insertSashimiScript() { + const script = `` + const indexHTMLPath = path.join(__dirname, '../build/index.html') + + const indexHTML = await fs.readFile(indexHTMLPath, 'utf-8') + const newHTML = indexHTML.replace('', `${script}`) + await fs.writeFile(indexHTMLPath, newHTML) +} + async function bundleArtifact() { await $`(cd ./build; tar -czf ../build.tar.gz ./)` } diff --git a/scripts/shadcn-add.mjs b/scripts/shadcn-add.mjs new file mode 100644 index 0000000..05cc571 --- /dev/null +++ b/scripts/shadcn-add.mjs @@ -0,0 +1,6 @@ +/* global $ */ + +;(async () => { + await $`npx shadcn-ui add ${process.argv[3]}` + await $`npx eslint --fix --ext .ts,.tsx ./src/components/ui` +})() diff --git a/scripts/verify-translations.mjs b/scripts/verify-translations.mjs index a2aa786..d2329ff 100644 --- a/scripts/verify-translations.mjs +++ b/scripts/verify-translations.mjs @@ -1,7 +1,6 @@ -/* global $ */ +import { join } from 'path' import fs from 'fs-extra' -import { join } from 'path' import get from 'lodash-es/get.js' await (async () => { diff --git a/src/App.test.tsx b/src/App.test.tsx deleted file mode 100644 index fc58bee..0000000 --- a/src/App.test.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React from 'react' -import { shallow } from 'enzyme' -import { BrowserRouter } from 'react-router-dom' - -import App from './App' - -it('renders without crashing', () => { - shallow( - - - , - ) -}) diff --git a/src/App.tsx b/src/App.tsx index 7fbfee5..1a9cbbf 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,191 +1,80 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' -import { find } from 'lodash-es' -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { + lazy, + Suspense, + useCallback, + useEffect, + useRef, + useState, +} from 'react' +import { Toaster, toast } from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { - Switch, + Navigate, Route, - Redirect, + Routes, useLocation, - useHistory, + useNavigate, } from 'react-router-dom' -import loadable from '@loadable/component' -import tw from 'twin.macro' -import styled from '@emotion/styled/macro' -import store from 'store2' -import ReactGA from 'react-ga' -import { toast, ToastContainer as OriginalToastContainer } from 'react-toastify' import { SWRConfig } from 'swr' -import 'react-toastify/dist/ReactToastify.css' -import FullLoading from './components/FullLoading' -import NewVersionAlert from './components/NewVersionAlert' -import ScrollToTop from './components/ScrollToTop' -import NetworkErrorModal from './components/NetworkErrorModal' +import FullLoading from '@/components/FullLoading' +import NetworkErrorModal from '@/components/NetworkErrorModal' +import NewVersionAlert from '@/components/NewVersionAlert' +import PageLayout from '@/components/PageLayout' +import RunInSurge from '@/components/RunInSurge' +import useTrafficUpdater from '@/hooks/useTrafficUpdater' +import HomePage from '@/pages/Home' +import { LandingPage } from '@/pages/Landing' import { usePlatformVersion, + useAppDispatch, + useHistory, useProfile, - useProfileDispatch, -} from './models/profile' -import { - RegularLanding as LandingPage, - SurgeLanding as SurgeLandingPage, -} from './pages/Landing' -import IndexPage from './pages/Index' -import PageLayout from './components/PageLayout' -import { Profile } from './types' -import { isRunInSurge } from './utils' -import { - ExistingProfiles, - LastUsedLanguage, - LastUsedProfile, -} from './utils/constant' -import { httpClient } from './utils/fetcher' - -const PoliciesPage = loadable(() => import('./pages/Policies'), { - fallback: , -}) -const RequestsPage = loadable(() => import('./pages/Requests'), { - fallback: , -}) -const TrafficPage = loadable(() => import('./pages/Traffic'), { - fallback: , -}) -const ModulesPage = loadable(() => import('./pages/Modules'), { - fallback: , -}) -const ScriptingPage = loadable(() => import('./pages/Scripting'), { - fallback: , -}) -const EvaluatePage = loadable(() => import('./pages/Scripting/Evaluate'), { - fallback: , -}) -const DnsPage = loadable(() => import('./pages/Dns'), { - fallback: , -}) -const DevicesPage = loadable(() => import('./pages/Devices'), { - fallback: , -}) -const ProfilePage = loadable(() => import('./pages/Profiles/Current'), { - fallback: , -}) -const ToastContainer = styled(OriginalToastContainer)` - ${tw`p-2 md:p-0`} - - .Toastify__toast { - ${tw`flex items-center px-3 py-3 bg-blue-100 rounded shadow-none`} - } - .Toastify__close-button, - .Toastify__toast-body { - ${tw`text-blue-700`} - } - .Toastify__toast-body { - ${tw`text-base`} - } - .Toastify__close-button { - ${tw`block ml-3 self-center`} - } - .Toastify__progress-bar { - ${tw`bg-blue-200`} - } - .Toastify__toast--error { - ${tw`bg-red-100`} - - .Toastify__close-button, .Toastify__toast-body { - ${tw`text-red-700`} - } - .Toastify__progress-bar { - ${tw`bg-red-200`} - } - } - .Toastify__toast--warning { - ${tw`bg-orange-100 border-l-4 border-orange-500`} - - .Toastify__close-button, .Toastify__toast-body { - ${tw`text-orange-700`} - } - .Toastify__progress-bar { - ${tw`bg-orange-200`} - } - } - .Toastify__toast--success { - ${tw`bg-green-100`} - - .Toastify__close-button, .Toastify__toast-body { - ${tw`text-green-700`} - } - .Toastify__progress-bar { - ${tw`bg-green-200`} - } - } -` - -if ( - !!process.env.REACT_APP_DEBUG_GA || - (process.env.NODE_ENV === 'production' && process.env.REACT_APP_ENABLE_GA) -) { - ReactGA.initialize('UA-146417304-2', { - debug: !!process.env.REACT_APP_DEBUG_GA, - }) -} +} from '@/store' +import { historyActions } from '@/store/slices/history' +import { profileActions } from '@/store/slices/profile' +import { isRunInSurge } from '@/utils' +import { httpClient } from '@/utils/fetcher' + +const PoliciesPage = lazy(() => import('@/pages/Policies')) +const RequestsPage = lazy(() => import('@/pages/Requests')) +const TrafficPage = lazy(() => import('@/pages/Traffic')) +const ModulesPage = lazy(() => import('@/pages/Modules')) +const ScriptingPage = lazy(() => import('@/pages/Scripting')) +const EvaluatePage = lazy(() => import('@/pages/Scripting/Evaluate')) +const DnsPage = lazy(() => import('@/pages/Dns')) +const DevicesPage = lazy(() => import('@/pages/Devices')) +const CurrentProfilePage = lazy(() => import('@/pages/Profiles/Current')) +const ManageProfilesPage = lazy(() => import('@/pages/Profiles/Manage')) const App: React.FC = () => { - const { t, i18n } = useTranslation() + const { t } = useTranslation() const [isNetworkModalOpen, setIsNetworkModalOpen] = useState(false) const location = useLocation() - const history = useHistory() - const profileDispatch = useProfileDispatch() + const navigate = useNavigate() + + const dispatch = useAppDispatch() + const platformVersion = usePlatformVersion() const profile = useProfile() - const [hasInit, setHasInit] = useState(false) + const history = useHistory() + const isCurrentVersionFetched = useRef(true) - const platformVersion = usePlatformVersion() + + useTrafficUpdater() const onCloseApplication = useCallback(() => { if (isRunInSurge()) { - store.remove(LastUsedProfile) - store.remove(ExistingProfiles) + dispatch(historyActions.deleteAllHistory()) } window.location.replace('/') - }, []) - - useEffect( - () => { - const existingProfiles = store.get(ExistingProfiles) - const lastId = store.get(LastUsedProfile) - const result = find(existingProfiles, { id: lastId }) - - if (result) { - profileDispatch({ - type: 'update', - payload: result, - }) - } - - setHasInit(true) - }, - // eslint-disable-next-line - [], - ) + }, [dispatch]) useEffect(() => { - if (hasInit && !profile && location.pathname !== '/') { - history.replace('/') + if (history && !profile && location.pathname !== '/') { + navigate('/', { replace: true }) } - }, [hasInit, history, location.pathname, profile]) - - useEffect(() => { - ReactGA.pageview(location.pathname) - }, [location.pathname]) - - useEffect(() => { - const language: string | null = store.get(LastUsedLanguage) - - if (language && language !== i18n.language) { - i18n.changeLanguage(language) - } - }, [i18n]) + }, [history, location, navigate, profile]) useEffect(() => { if ( @@ -205,12 +94,11 @@ const App: React.FC = () => { const currentPlatformVersion = res.headers['x-surge-version'] if (currentPlatformVersion !== platformVersion) { - profileDispatch({ - type: 'updatePlatformVersion', - payload: { + dispatch( + profileActions.updatePlatformVersion({ platformVersion: currentPlatformVersion, - }, - }) + }), + ) } isCurrentVersionFetched.current = false @@ -219,7 +107,7 @@ const App: React.FC = () => { console.error(err) toast.error(t('common.surge_too_old')) }) - }, [location, platformVersion, profile?.platform, profileDispatch, t]) + }, [dispatch, location, platformVersion, profile?.platform, t]) return ( { refreshWhenOffline: true, }} > - - + + - + + + + - - - {isRunInSurge() ? : } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + }> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ) diff --git a/src/AppContainer.tsx b/src/AppContainer.tsx index b916dcf..e887dc0 100644 --- a/src/AppContainer.tsx +++ b/src/AppContainer.tsx @@ -1,9 +1,6 @@ -import createCache from '@emotion/cache' -import { CacheProvider } from '@emotion/core' -import { ModalProvider } from '@sumup/circuit-ui' -import { light } from '@sumup/design-tokens' -import { ThemeProvider } from 'emotion-theming' -import React, { Suspense } from 'react' +import React, { ReactNode, Suspense } from 'react' +import { HelmetProvider } from 'react-helmet-async' +import { Provider as ReduxProvider } from 'react-redux' import { BrowserRouter, BrowserRouterProps, @@ -11,10 +8,13 @@ import { HashRouterProps, } from 'react-router-dom' -import { ProfileProvider } from './models/profile' +import Bootstrap from '@/bootstrap' +import { ThemeProvider } from '@/components/ThemeProvider' +import { UIProvider } from '@/components/UIProvider' +import { store } from '@/store' const ReactRouter: React.FC = (args) => { - return process.env.REACT_APP_HASH_ROUTER ? ( + return process.env.REACT_APP_HASH_ROUTER === 'true' ? ( {args.children} ) : ( @@ -22,22 +22,21 @@ const ReactRouter: React.FC = (args) => { ) } -const styleCache = createCache({ - key: 'yasd', -}) -const AppContainer: React.FC = ({ children }) => { +const AppContainer: React.FC<{ children: ReactNode }> = ({ children }) => { return ( }> - - - - - {children} + + + + + + {children} + - - - + + + ) } diff --git a/src/bootstrap/Bootstrap.tsx b/src/bootstrap/Bootstrap.tsx new file mode 100644 index 0000000..95affb1 --- /dev/null +++ b/src/bootstrap/Bootstrap.tsx @@ -0,0 +1,48 @@ +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useLocation } from 'react-router-dom' +import store from 'store2' + +import { useAppDispatch, useHistory } from '@/store' +import { historyActions } from '@/store/slices/history' +import { isRunInSurge } from '@/utils' +import { LastUsedLanguage } from '@/utils/constant' + +export const Bootstrap: React.FC<{ + children: React.ReactNode +}> = ({ children }) => { + const { i18n } = useTranslation() + const dispatch = useAppDispatch() + const history = useHistory() + const location = useLocation() + + const [isTranslationLoaded, setIsTranslationLoaded] = useState(false) + + useEffect(() => { + const loadLastUsedProfile = location.pathname !== '/' || isRunInSurge() + + dispatch( + historyActions.loadHistoryFromLocalStorage({ + loadLastUsedProfile, + }), + ) + }, [dispatch, location.pathname]) + + useEffect(() => { + const language: string | null = store.get(LastUsedLanguage) + + if (language && language !== i18n.language) { + i18n.changeLanguage(language).then(() => { + setIsTranslationLoaded(true) + }) + } else { + setIsTranslationLoaded(true) + } + }, [i18n]) + + if (history === undefined || !isTranslationLoaded) { + return null + } + + return <>{children} +} diff --git a/src/bootstrap/index.ts b/src/bootstrap/index.ts new file mode 100644 index 0000000..129f673 --- /dev/null +++ b/src/bootstrap/index.ts @@ -0,0 +1 @@ +export { Bootstrap as default } from './Bootstrap' diff --git a/src/components/ActionsModal.tsx b/src/components/ActionsModal.tsx index d015808..60b6680 100644 --- a/src/components/ActionsModal.tsx +++ b/src/components/ActionsModal.tsx @@ -1,54 +1,48 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' -import { noop } from 'lodash-es' -import { KeyboardEvent, MouseEvent } from 'react' -import css from '@emotion/css/macro' +import React from 'react' import { useTranslation } from 'react-i18next' -import tw from 'twin.macro' + +import { Button } from '@/components/ui/button' import { - ButtonGroup, - Button, - ModalFooter, - ModalHeader, - ModalWrapper, -} from '@sumup/circuit-ui' + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' -interface Action { +export type Action = { id: number | string title: string onClick: () => void } -interface ActionsModalProps { +type ActionsModalProps = { title: string - onClose: (event?: MouseEvent | KeyboardEvent) => void actions: ReadonlyArray -} +} & Omit, 'children'> const ActionsModal = ({ title, - onClose, actions, + ...props }: ActionsModalProps): JSX.Element => { const { t } = useTranslation() return ( - - + + + + {title} + -
- {actions.map((action) => ( - - ))} -
-
+
+ {actions.map((action) => ( + + ))} +
+ + ) } diff --git a/src/components/Ad.tsx b/src/components/Ad.tsx deleted file mode 100644 index 76be44e..0000000 --- a/src/components/Ad.tsx +++ /dev/null @@ -1,90 +0,0 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' -import React, { useEffect, useRef, useState } from 'react' -import tw from 'twin.macro' -import ReactGA from 'react-ga' -import axios from 'axios' -import { isRunInSurge } from '../utils' - -interface AdData { - id: number - name: string - url: string - image?: string -} - -const Ad: React.FC = () => { - const showDynamicAd = useRef(!!process.env.REACT_APP_SHOW_AD) - const [ad, setAd] = useState() - - useEffect(() => { - let isMounted = true - - if (isRunInSurge()) { - return - } - - if (showDynamicAd.current) { - axios - .get<{ list: Array }>( - 'https://cdn.jsdelivr.net/gh/geekdada/ad-json/ad.json', - { - timeout: 3000, - }, - ) - .then(({ data }) => { - const { list } = data - const adList = list.filter((item) => item.id === 20201013) - - if (adList.length) { - isMounted && setAd(adList[0]) - } else { - throw new Error('Target ad not found') - } - }) - .catch(() => { - isMounted && - setAd({ - id: 1, - name: '请我喝咖啡!', - url: 'https://surgio.royli.dev/support.html', - }) - }) - } else { - isMounted && - setAd({ - id: 1, - name: '请我喝咖啡!', - url: 'https://surgio.royli.dev/support.html', - }) - } - - return () => { - isMounted = false - } - }, []) - - return ( - - {ad && ( - -
- {showDynamicAd.current && ( - - AD - - )} - - {ad.name} -
-
- )} -
- ) -} - -export default Ad diff --git a/src/components/BackButton/index.tsx b/src/components/BackButton/index.tsx index e461c61..2da356f 100644 --- a/src/components/BackButton/index.tsx +++ b/src/components/BackButton/index.tsx @@ -1,26 +1,25 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' -import { IconButton } from '@sumup/circuit-ui' -import { ChevronLeft } from '@sumup/icons' import React from 'react' -import css from '@emotion/css/macro' -import tw from 'twin.macro' -import { useHistory } from 'react-router-dom' +import { useNavigate } from 'react-router-dom' +import { ArrowLeftIcon } from 'lucide-react' -const BackButton: React.FC = () => { - const history = useHistory() +import { Button } from '@/components/ui/button' + +const BackButton = ({ title }: { title?: string }) => { + const navigate = useNavigate() return ( - history.goBack()} - label="back" - tw="w-8 h-8 mr-3 self-center" - css={css` - padding: 0.3rem; - `} - > - - +
+ + {title ? {title} : null} +
) } diff --git a/src/components/BottomPanel.tsx b/src/components/BottomPanel.tsx new file mode 100644 index 0000000..4973ce2 --- /dev/null +++ b/src/components/BottomPanel.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import { css } from '@emotion/react' + +import { cn } from '@/utils/shadcn' + +const BottomPanel = ({ + className, + children, + ...props +}: React.HTMLAttributes) => { + return ( +
+
+ {children} +
+
+ ) +} + +export default BottomPanel diff --git a/src/components/ChangeLanguage.tsx b/src/components/ChangeLanguage.tsx index ea543a4..d08a1df 100644 --- a/src/components/ChangeLanguage.tsx +++ b/src/components/ChangeLanguage.tsx @@ -1,13 +1,16 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' -import { Select } from '@sumup/circuit-ui' -import { ChangeEventHandler, useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' +import dayjs from 'dayjs' import store from 'store2' -import tw from 'twin.macro' -import css from '@emotion/css/macro' -import { LastUsedLanguage } from '../utils/constant' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { LastUsedLanguage } from '@/utils/constant' const ChangeLanguage = (): JSX.Element => { const { i18n } = useTranslation() @@ -23,28 +26,63 @@ const ChangeLanguage = (): JSX.Element => { ] const [isLoading, setIsLoading] = useState(false) - const onChange: ChangeEventHandler = useCallback( - (e) => { + const onChange = useCallback( + async (newVal: string) => { setIsLoading(true) - store.set(LastUsedLanguage, e.target.value) - i18n.changeLanguage(e.target.value).finally(() => setIsLoading(false)) + store.set(LastUsedLanguage, newVal) + + try { + await i18n.changeLanguage(newVal) + } catch (err) { + console.error(err) + } finally { + setIsLoading(false) + } }, [i18n], ) + useEffect(() => { + switch (i18n.language) { + case 'zh': + setZH().catch(console.error) + break + case 'en': + setEN().catch(console.error) + break + } + }, [i18n.language]) + return ( -
- { + onChange(newVal) + }} + disabled={isLoading} + > + + + + + {options.map((option) => ( + + {option.label} + + ))} + + ) } +async function setZH() { + const mod = await import('dayjs/locale/zh') + dayjs.locale(mod.default) +} + +async function setEN() { + const mod = await import('dayjs/locale/en') + dayjs.locale(mod.default) +} + export default ChangeLanguage diff --git a/src/components/CodeContent.tsx b/src/components/CodeContent.tsx new file mode 100644 index 0000000..a20d4c5 --- /dev/null +++ b/src/components/CodeContent.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { css } from '@emotion/react' + +import { cn } from '@/utils/shadcn' + +type CodeContentProps = { + content?: string +} & React.HTMLAttributes + +const CodeContent = ({ className, content, ...props }: CodeContentProps) => { + return ( +
+      {content}
+    
+ ) +} + +export default CodeContent diff --git a/src/components/CodeMirror/CodeMirror.tsx b/src/components/CodeMirror/CodeMirror.tsx new file mode 100644 index 0000000..26e674d --- /dev/null +++ b/src/components/CodeMirror/CodeMirror.tsx @@ -0,0 +1,46 @@ +import React, { useMemo } from 'react' +import { javascript } from '@codemirror/lang-javascript' +import { EditorView } from '@codemirror/view' +import { css } from '@emotion/react' +import { material } from '@uiw/codemirror-theme-material' +import { + default as ReactCodeMirror, + ReactCodeMirrorProps, +} from '@uiw/react-codemirror' + +import { cn } from '@/utils/shadcn' + +type CodeMirrorProps = { + className?: string + isJavaScript?: boolean +} & ReactCodeMirrorProps + +const CodeMirror = ({ className, isJavaScript, ...props }: CodeMirrorProps) => { + const extensions = useMemo(() => { + const exts = [EditorView.lineWrapping] + + if (isJavaScript) { + exts.push(javascript()) + } + + return exts + }, [isJavaScript]) + + return ( + + ) +} + +export default CodeMirror diff --git a/src/components/CodeMirror/index.ts b/src/components/CodeMirror/index.ts new file mode 100644 index 0000000..a818418 --- /dev/null +++ b/src/components/CodeMirror/index.ts @@ -0,0 +1 @@ +export { default } from './CodeMirror' diff --git a/src/components/CodeMirrorLoading.tsx b/src/components/CodeMirrorLoading.tsx index 92be701..799e70a 100644 --- a/src/components/CodeMirrorLoading.tsx +++ b/src/components/CodeMirrorLoading.tsx @@ -1,6 +1,3 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' -import tw from 'twin.macro' import React from 'react' import { useTranslation } from 'react-i18next' @@ -8,7 +5,7 @@ const CodeMirrorLoading = (): JSX.Element => { const { t } = useTranslation() return ( -
+
{t('common.is_loading')}...
) diff --git a/src/components/DarkModeToggle.tsx b/src/components/DarkModeToggle.tsx new file mode 100644 index 0000000..c7db93f --- /dev/null +++ b/src/components/DarkModeToggle.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import { useTranslation } from 'react-i18next' +import { Moon, Sun } from 'lucide-react' + +import { useTheme } from '@/components/ThemeProvider' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' + +export default function DarkModeToggle() { + const { setTheme } = useTheme() + const { t } = useTranslation() + + return ( + + + + + + setTheme('light')}> + {t('common.light')} + + setTheme('dark')}> + {t('common.dark')} + + setTheme('system')}> + {t('common.system')} + + + + ) +} diff --git a/src/components/Data/DataRowMain.tsx b/src/components/Data/DataRowMain.tsx new file mode 100644 index 0000000..313fff1 --- /dev/null +++ b/src/components/Data/DataRowMain.tsx @@ -0,0 +1,56 @@ +import React, { useCallback } from 'react' +import { ChevronRight } from '@sumup/icons' + +import { cn } from '@/utils/shadcn' + +type DataRowMainProps = { + onClick?: () => void + hideArrow?: boolean + destructive?: boolean + disabled?: boolean + responsiveFont?: boolean +} & React.HTMLAttributes + +export const DataRowMain = ({ + children, + className, + onClick, + hideArrow, + destructive, + disabled, + responsiveFont, + ...props +}: DataRowMainProps) => { + const handleClick = useCallback(() => { + if (disabled) return + onClick?.() + }, [disabled, onClick]) + const isClickable = typeof onClick === 'function' + + const clickableChildren = ( + <> +
{children}
+ {!hideArrow && } + + ) + + responsiveFont = responsiveFont ?? true + + return ( +
handleClick()} + {...props} + > + {isClickable ? clickableChildren : children} +
+ ) +} diff --git a/src/components/Data/index.tsx b/src/components/Data/index.tsx index b02e497..91e33fb 100644 --- a/src/components/Data/index.tsx +++ b/src/components/Data/index.tsx @@ -1,19 +1,33 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' -import css from '@emotion/css/macro' -import styled from '@emotion/styled/macro' -import tw from 'twin.macro' import React from 'react' +import styled from '@emotion/styled' +import tw from 'twin.macro' + +import { cn } from '@/utils/shadcn' + +export { DataRowMain } from './DataRowMain' + +export const DataGroup: React.FC<{ + title?: string + children: React.ReactNode + className?: string + responsiveTitle?: boolean +}> = (props) => { + const responsiveTitle = props.responsiveTitle ?? true -export const DataGroup: React.FC<{ title?: string }> = (props) => { return ( -
+
{props.title && ( -
+
{props.title}
)} -
+ +
{props.children}
@@ -22,18 +36,6 @@ export const DataGroup: React.FC<{ title?: string }> = (props) => { export const DataRow = styled.div`` -export const DataRowMain = styled.div` - ${tw`flex items-center justify-between px-3 py-3 leading-normal text-gray-800`} - - & > div:last-of-type { - ${tw`text-gray-600`} - } -` - export const DataRowSub = styled.div` - ${tw`flex items-center justify-between px-3 leading-normal text-xs text-gray-800 lg:text-sm lg:leading-relaxed`} - - & > div:last-of-type { - ${tw`text-gray-600`} - } + ${tw`flex items-center justify-between px-3 leading-normal text-xs lg:text-sm lg:leading-relaxed`} ` diff --git a/src/components/FixedFullscreenContainer.tsx b/src/components/FixedFullscreenContainer.tsx index d25eb86..d854e3f 100644 --- a/src/components/FixedFullscreenContainer.tsx +++ b/src/components/FixedFullscreenContainer.tsx @@ -1,11 +1,11 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' -import css from '@emotion/css/macro' -import tw from 'twin.macro' import React from 'react' +import { css } from '@emotion/react' + +import { cn } from '@/utils/shadcn' const FixedFullscreenContainer: React.FC<{ offsetBottom?: boolean + children: React.ReactNode | React.ReactNode[] }> = (props) => { let offsetBottom = true @@ -15,16 +15,17 @@ const FixedFullscreenContainer: React.FC<{ return (
-
{props.children}
+
{props.children}
) } diff --git a/src/components/FullLoading/index.tsx b/src/components/FullLoading/index.tsx index 86538a3..0fa747d 100644 --- a/src/components/FullLoading/index.tsx +++ b/src/components/FullLoading/index.tsx @@ -1,20 +1,10 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' import React from 'react' -import styled from '@emotion/styled/macro' -import { Spinner } from '@sumup/circuit-ui' import tw from 'twin.macro' -const FullLoadingWrapper = styled.div` - ${tw`fixed top-0 right-0 bottom-0 left-0 flex items-center justify-center`} -` +const FullLoadingWrapper = tw.div`fixed top-0 right-0 bottom-0 left-0 flex items-center justify-center` const FullLoading: React.FC = () => { - return ( - - - - ) + return } export default FullLoading diff --git a/src/components/HorizontalSafeArea.tsx b/src/components/HorizontalSafeArea.tsx new file mode 100644 index 0000000..9c74a92 --- /dev/null +++ b/src/components/HorizontalSafeArea.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import { css } from '@emotion/react' + +import { cn } from '@/utils/shadcn' + +type HorizontalSafeAreaProps = React.HTMLAttributes + +const HorizontalSafeArea: React.FC = ({ + className, + children, + ...props +}) => { + return ( +
+
+ {children} +
+
+ ) +} + +export default HorizontalSafeArea diff --git a/src/components/ListCell.tsx b/src/components/ListCell.tsx new file mode 100644 index 0000000..88dcc40 --- /dev/null +++ b/src/components/ListCell.tsx @@ -0,0 +1,66 @@ +import React from 'react' +import { css } from '@emotion/react' +import { cva, VariantProps } from 'class-variance-authority' + +import { cn } from '@/utils/shadcn' + +const variants = cva('flex w-full flex-col select-none', { + variants: { + interactive: { + true: 'cursor-pointer hover:bg-muted', + false: '', + }, + }, + defaultVariants: { + interactive: true, + }, +}) + +type ListCellProps = { + children: React.ReactNode +} & React.HTMLAttributes & + VariantProps + +const ListCell: React.FC = ({ + children, + className, + interactive, + ...props +}) => { + return ( +
+ {children} +
+ ) +} + +type ListFullHeightCellProps = { + children: React.ReactNode +} & React.HTMLAttributes + +const ListFullHeightCell = ({ + children, + className, + ...props +}: ListFullHeightCellProps) => { + return ( +
+ {children} +
+ ) +} + +export { ListCell, ListFullHeightCell } diff --git a/src/components/NetworkErrorModal.tsx b/src/components/NetworkErrorModal.tsx index 0cf0440..af2d527 100644 --- a/src/components/NetworkErrorModal.tsx +++ b/src/components/NetworkErrorModal.tsx @@ -1,18 +1,15 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' import React, { KeyboardEvent, MouseEvent } from 'react' -import { - Button, - ButtonGroup, - Modal, - ModalFooter, - ModalHeader, - ModalWrapper, -} from '@sumup/circuit-ui' -import styled from '@emotion/styled/macro' -import css from '@emotion/css/macro' import { useTranslation } from 'react-i18next' -import tw from 'twin.macro' + +import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' interface NetworkErrorModalProps { onClose: (event?: MouseEvent | KeyboardEvent) => void @@ -28,32 +25,40 @@ const NetworkErrorModal: React.FC = ({ const { t } = useTranslation() return ( - - {({ onClose }) => ( - - -
{t('common.network_error_message')}
- - - {reloadButton ? ( - - ) : ( - - )} - - - -
- )} -
+ ) : ( + + )} + + + + + ) } diff --git a/src/components/NewVersionAlert.tsx b/src/components/NewVersionAlert.tsx index 42b3af4..41f4af9 100644 --- a/src/components/NewVersionAlert.tsx +++ b/src/components/NewVersionAlert.tsx @@ -1,32 +1,33 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' import React, { useEffect, useState } from 'react' -import { - Button, - ButtonGroup, - Modal, - ModalFooter, - ModalHeader, - ModalWrapper, -} from '@sumup/circuit-ui' -import styled from '@emotion/styled/macro' -import css from '@emotion/css/macro' import { useTranslation } from 'react-i18next' -import tw from 'twin.macro' -import store from 'store2' import satisfies from 'semver/functions/satisfies' +import store from 'store2' -import { LastUsedVersion } from '../utils/constant' +import { Button } from '@/components/ui/button' +import { ButtonGroup } from '@/components/ui/button-group' +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { LastUsedVersion } from '@/utils/constant' const currentVersion = process.env.REACT_APP_VERSION as string const NewVersionAlert: React.FC = () => { const [isOpen, setIsOpen] = useState(false) - const [versionUrl, setVersionUrl] = useState() + const [versionUrl, setVersionUrl] = useState('#') const { t } = useTranslation() useEffect(() => { const lastUsedVersion = store.get(LastUsedVersion) + const isSWEnabled = process.env.REACT_APP_USE_SW === 'true' + + if (!isSWEnabled) { + return + } if (lastUsedVersion && !satisfies(currentVersion, `~${lastUsedVersion}`)) { setVersionUrl( @@ -39,28 +40,28 @@ const NewVersionAlert: React.FC = () => { }, []) return ( - { - setIsOpen(false) + { + setIsOpen(open) }} > - {({ onClose }) => ( - - -
{t('new_version_alert.message')}
- - - - - - - -
- )} -
+ + + {t('new_version_alert.title')} + +
{t('new_version_alert.message')}
+ + + + + + + +
+ ) } diff --git a/src/components/PageContainer.tsx b/src/components/PageContainer.tsx index 1c33a5e..8225318 100644 --- a/src/components/PageContainer.tsx +++ b/src/components/PageContainer.tsx @@ -1,11 +1,9 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' -import css from '@emotion/css/macro' -import tw from 'twin.macro' import React from 'react' -const PageContainer: React.FC = ({ children }) => { - return
{children}
+const PageContainer: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + return
{children}
} export default PageContainer diff --git a/src/components/PageLayout/index.tsx b/src/components/PageLayout/index.tsx index 5eb74c9..f774009 100644 --- a/src/components/PageLayout/index.tsx +++ b/src/components/PageLayout/index.tsx @@ -1,11 +1,27 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' -import styled from '@emotion/styled/macro' import React from 'react' -import tw from 'twin.macro' -const PageLayout = styled.main` - ${tw`relative antialiased`}; -` +import { cn } from '@/utils/shadcn' + +const PageLayout: React.FC> = ({ + children, + className, + ...props +}) => { + return ( +
+
+
+ {children} +
+
+
+ ) +} export default PageLayout diff --git a/src/components/PageTitle/index.tsx b/src/components/PageTitle/index.tsx index 710490a..898d52a 100644 --- a/src/components/PageTitle/index.tsx +++ b/src/components/PageTitle/index.tsx @@ -1,18 +1,16 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' -import { Heading } from '@sumup/circuit-ui' -import { Spinner } from '@sumup/icons' import React, { useEffect, useMemo, useState } from 'react' -import styled from '@emotion/styled/macro' -import css from '@emotion/css/macro' -import tw from 'twin.macro' +import { css } from '@emotion/react' + +import { TypographyH3 } from '@/components/ui/typography' +import { cn } from '@/utils/shadcn' + import BackButton from '../BackButton' interface PageTitleProps { title: string hasAutoRefresh?: boolean defaultAutoRefreshState?: boolean - onAuthRefreshStateChange?: (newState: boolean) => void + onAutoRefreshStateChange?: (newState: boolean) => void sticky?: boolean } @@ -26,51 +24,55 @@ const PageTitle: React.FC = (props) => { ) useEffect(() => { - if (props.hasAutoRefresh && props.onAuthRefreshStateChange) { - props.onAuthRefreshStateChange(isAutoRefresh) + if (props.hasAutoRefresh && props.onAutoRefreshStateChange) { + props.onAutoRefreshStateChange(isAutoRefresh) } - }, [ - isAutoRefresh, - props, - props.hasAutoRefresh, - props.onAuthRefreshStateChange, - ]) + }, [isAutoRefresh, props]) return ( -
- -
{props.title}
+
{props.hasAutoRefresh && (
setIsAutoRefresh(!isAutoRefresh)} + className={cn( + 'relative bg-green-100 cursor-pointer w-7 h-7 rounded-full flex items-center justify-center transition-colors duration-200 ease-in-out', + isAutoRefresh && 'bg-red-100', + )} css={[ - tw`bg-blue-500 text-white cursor-pointer w-10 h-10 rounded-lg flex items-center justify-center transition-colors duration-200 ease-in-out`, - isAutoRefresh && tw`bg-red-400`, css` margin-right: env(safe-area-inset-right); `, ]} > - + +
)} -
+ ) } diff --git a/src/components/ProfileCell/index.tsx b/src/components/ProfileCell/index.tsx index cd1154d..2c85203 100644 --- a/src/components/ProfileCell/index.tsx +++ b/src/components/ProfileCell/index.tsx @@ -1,22 +1,22 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' import axios from 'axios' -import React, { MouseEventHandler, useEffect, useState } from 'react' -import styled from '@emotion/styled/macro' -import css from '@emotion/css/macro' +import { Trash2 } from 'lucide-react' import tw from 'twin.macro' -import { Bin, PaperPlane } from '@sumup/icons' -import { IconButton } from '@sumup/circuit-ui' -import { Profile } from '../../types' +import { Button } from '@/components/ui/button' +import { useConfirm } from '@/components/UIProvider' +import { Profile } from '@/types' +import { cn } from '@/utils/shadcn' interface ProfileCellProps { profile: Profile checkConnectivity?: boolean - onClick?: MouseEventHandler + onClick?: () => void + onDelete?: () => void showDelete?: boolean - onDelete?: MouseEventHandler variant?: 'spread' | 'left' + className?: string } const ProfileCell: React.FC = ({ @@ -26,37 +26,42 @@ const ProfileCell: React.FC = ({ showDelete, onDelete, variant = 'spread', + className, }) => { const [available, setAvailable] = useState(undefined) + const confirm = useConfirm() const variantStyle = variant === 'spread' - ? tw`flex-row justify-between items-center` - : tw`flex-col justify-start items-start` - - const clickHandler: MouseEventHandler = (e) => { - e.stopPropagation() - e.preventDefault() + ? 'flex-row justify-between items-center' + : 'flex-col justify-start items-start' + const { t } = useTranslation() + const clickHandler = () => { if (available && onClick) { - onClick(e) + onClick() } } - const deleteHandler: MouseEventHandler = (e) => { - e.stopPropagation() - e.preventDefault() + const deleteHandler = async () => { + if (!onDelete) { + return undefined + } + + const result = await confirm({ + title: t('profiles.confirm_delete_profile.title'), + }) - if (onDelete) { - onDelete(e) + if (result) { + onDelete() } } const getCursorStyle = () => { if (onClick) { if (available) { - return tw`cursor-pointer` + return 'cursor-pointer' } - return tw`cursor-not-allowed` + return 'cursor-not-allowed' } return null } @@ -91,19 +96,23 @@ const ProfileCell: React.FC = ({ return (
{ + e.stopPropagation() + e.preventDefault() + clickHandler() + }} > -
-
+
+
{profile.name}
-
+ +
{checkConnectivity && ( -
+
{available && ( - + )} = ({ />
)} -
+
{profile.host}:{profile.port} @@ -123,26 +132,19 @@ const ProfileCell: React.FC = ({
{showDelete && ( -
- +
)}
diff --git a/src/components/RunInSurge.tsx b/src/components/RunInSurge.tsx new file mode 100644 index 0000000..eb06561 --- /dev/null +++ b/src/components/RunInSurge.tsx @@ -0,0 +1,14 @@ +import React from 'react' + +import { isRunInSurge } from '@/utils' + +const RunInSurge: React.FC<{ + children: React.ReactNode + not?: boolean +}> = ({ not, children }) => { + const runInSurge = isRunInSurge() + + return not ? !runInSurge && <>{children} : runInSurge && <>{children} +} + +export default RunInSurge diff --git a/src/components/SWUpdateNotification.tsx b/src/components/SWUpdateNotification.tsx new file mode 100644 index 0000000..f78b6e8 --- /dev/null +++ b/src/components/SWUpdateNotification.tsx @@ -0,0 +1,36 @@ +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' +import { InfoIcon } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { TypographyP } from '@/components/ui/typography' + +const SWUpdateNotification = ({ + registration, +}: { + registration?: ServiceWorkerRegistration +}) => { + const { t } = useTranslation() + + const onClick = useCallback(() => { + if (registration) { + registration.waiting?.postMessage({ type: 'SKIP_WAITING' }) + } + + window.location.reload() + }, [registration]) + + return ( +
+ +
+ {t('common.sw_updated')} + +
+
+ ) +} + +export default SWUpdateNotification diff --git a/src/components/ScrollToTop/index.tsx b/src/components/ScrollToTop/index.tsx deleted file mode 100644 index 6b6eaae..0000000 --- a/src/components/ScrollToTop/index.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useModal } from '@sumup/circuit-ui' -import React, { useEffect } from 'react' -import { useHistory, useLocation } from 'react-router-dom' - -function useScrollMemory(): void { - const history = useHistory<{ scroll: number } | undefined>() - - useEffect(() => { - const { push, replace } = history - - // Override the history PUSH method to automatically set scroll state. - history.push = (path: string) => { - push(path, { scroll: window.scrollY }) - } - // Override the history REPLACE method to automatically set scroll state. - history.replace = (path: string) => { - replace(path, { scroll: window.scrollY }) - } - - // Listen for location changes and set the scroll position accordingly. - // @ts-ignore - const unregister = history.listen((location, action) => { - window.scrollTo(0, action !== 'POP' ? 0 : location.state?.scroll ?? 0) - }) - - // Unregister listener when component unmounts. - return () => { - unregister() - } - }, [history]) -} - -const ScrollToTop: React.FC = () => { - const { pathname } = useLocation() - const { isModalOpen, removeModal } = useModal() - useScrollMemory() - - useEffect( - () => { - if (isModalOpen) { - removeModal() - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [pathname], - ) - - return <> -} - -export default ScrollToTop diff --git a/src/components/StatusChip/StatusChip.tsx b/src/components/StatusChip/StatusChip.tsx new file mode 100644 index 0000000..89255dc --- /dev/null +++ b/src/components/StatusChip/StatusChip.tsx @@ -0,0 +1,47 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/utils/shadcn' + +const chipVariants = cva('inline-block font-medium ring-1 ring-inset', { + variants: { + variant: { + info: 'text-green-700 bg-green-50 ring-green-600/20', + error: 'text-red-700 bg-red-50 ring-red-600/10', + warn: 'text-yellow-600 bg-yellow-50 ring-yellow-500/10', + }, + size: { + default: 'text-sm rounded-md py-1 px-2', + sm: 'text-xs rounded-md py-0.5 px-2', + lg: 'text-base rounded-lg py-2 px-3', + }, + }, + defaultVariants: { + variant: 'info', + size: 'default', + }, +}) + +export interface StatusChipProps + extends React.ButtonHTMLAttributes, + VariantProps { + text?: string +} + +const StatusChip = React.forwardRef( + ({ className, text, variant, size, ...props }, ref) => { + return ( +
+ {text || variant} +
+ ) + }, +) + +StatusChip.displayName = 'StatusChip' + +export { StatusChip } diff --git a/src/components/StatusChip/index.ts b/src/components/StatusChip/index.ts new file mode 100644 index 0000000..91b4731 --- /dev/null +++ b/src/components/StatusChip/index.ts @@ -0,0 +1 @@ +export * from './StatusChip' diff --git a/src/components/ThemeProvider/ThemeProvider.tsx b/src/components/ThemeProvider/ThemeProvider.tsx new file mode 100644 index 0000000..e066212 --- /dev/null +++ b/src/components/ThemeProvider/ThemeProvider.tsx @@ -0,0 +1,78 @@ +import React, { createContext, useContext, useEffect, useState } from 'react' +import { Helmet } from 'react-helmet-async' + +type ThemeProviderProps = { + children: React.ReactNode + defaultTheme?: string + storageKey?: string +} + +type ThemeProviderState = { + theme: string + setTheme: (theme: string) => void +} + +const initialState = { + theme: 'system', + setTheme: () => null, +} + +const ThemeProviderContext = createContext(initialState) + +export function ThemeProvider({ + children, + defaultTheme = 'system', + storageKey = 'yasd-ui-theme', + ...props +}: ThemeProviderProps) { + const [theme, setTheme] = useState( + () => localStorage.getItem(storageKey) || defaultTheme, + ) + + useEffect(() => { + const root = window.document.documentElement + + root.classList.remove('light', 'dark') + + if (theme === 'system') { + const systemTheme = window.matchMedia('(prefers-color-scheme: dark)') + .matches + ? 'dark' + : 'light' + + root.classList.add(systemTheme) + return + } + + root.classList.add(theme) + }, [theme]) + + const value = { + theme, + setTheme: (theme: string) => { + localStorage.setItem(storageKey, theme) + setTheme(theme) + }, + } + + return ( + + + + + {children} + + ) +} + +export const useTheme = () => { + const context = useContext(ThemeProviderContext) + + if (context === undefined) + throw new Error('useTheme must be used within a ThemeProvider') + + return context +} diff --git a/src/components/ThemeProvider/index.ts b/src/components/ThemeProvider/index.ts new file mode 100644 index 0000000..4c611bf --- /dev/null +++ b/src/components/ThemeProvider/index.ts @@ -0,0 +1 @@ +export * from './ThemeProvider' diff --git a/src/components/UIProvider/UIProvider.tsx b/src/components/UIProvider/UIProvider.tsx new file mode 100644 index 0000000..f129496 --- /dev/null +++ b/src/components/UIProvider/UIProvider.tsx @@ -0,0 +1,116 @@ +import React, { createContext, useCallback } from 'react' + +import Confirmations from './components/Confirmations' + +import type { ConfirmProperties } from './types' + +interface UIState { + confirmations: ConfirmProperties[] +} + +interface UIContext extends UIState { + confirm: (properties: ConfirmProperties) => Promise + cleanConfirmation: (index: number) => Promise +} + +const UIContext = createContext(undefined) + +export const UIProvider = ({ children }: { children: React.ReactNode }) => { + const [uiState, setUIState] = React.useState({ + confirmations: [], + }) + + const confirm = useCallback(async (properties: ConfirmProperties) => { + return new Promise((resolve) => { + setUIState((prevState) => { + return { + ...prevState, + confirmations: [ + ...prevState.confirmations, + { + ...properties, + open: true, + onConfirm: () => { + if (properties.onConfirm) { + properties.onConfirm() + resolve(true) + } else { + resolve(true) + } + }, + onCancel: () => { + if (properties.onCancel) { + properties.onCancel() + resolve(false) + } else { + resolve(false) + } + }, + }, + ], + } + }) + }) + }, []) + + const cleanConfirmation = useCallback(async (index: number) => { + setUIState((prevState) => { + const confirmations = [...prevState.confirmations] + confirmations[index].open = false + + return { + ...prevState, + confirmations, + } + }) + + await new Promise((resolve) => setTimeout(resolve, 200)) + + setUIState((prevState) => { + const confirmations = [...prevState.confirmations] + confirmations.splice(index, 1) + + return { + ...prevState, + confirmations, + } + }) + }, []) + + return ( + + + + {children} + + ) +} + +export const useConfirm = () => { + const context = React.useContext(UIContext) + + if (context === undefined) { + throw new Error('useConfirm must be used within a UIProvider') + } + + return context.confirm +} + +export const useConfirmations = () => { + const context = React.useContext(UIContext) + + if (context === undefined) { + throw new Error('useConfirmations must be used within a UIProvider') + } + + return { + confirmations: context.confirmations, + cleanConfirmation: context.cleanConfirmation, + } +} diff --git a/src/components/UIProvider/components/Confirmations.tsx b/src/components/UIProvider/components/Confirmations.tsx new file mode 100644 index 0000000..1724022 --- /dev/null +++ b/src/components/UIProvider/components/Confirmations.tsx @@ -0,0 +1,77 @@ +import React, { useCallback } from 'react' +import { useTranslation } from 'react-i18next' + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' + +import { useConfirmations } from '../UIProvider' + +const Confirmations = () => { + const { confirmations, cleanConfirmation } = useConfirmations() + const { t } = useTranslation() + + const handleAction = useCallback( + (index: number) => { + const confirmation = confirmations[index] + + if (confirmation.onConfirm) { + confirmation.onConfirm() + } + + cleanConfirmation(index) + }, + [cleanConfirmation, confirmations], + ) + + const handleCancel = useCallback( + (index: number) => { + const confirmation = confirmations[index] + + if (confirmation.onCancel) { + confirmation.onCancel() + } + + cleanConfirmation(index) + }, + [cleanConfirmation, confirmations], + ) + + return ( + <> + {confirmations.map((confirmation, index) => ( + + + + {confirmation.title} + + {confirmation.description ? ( + + {confirmation.description} + + ) : null} + + + + handleCancel(index)}> + {confirmation.cancelText ?? t('common.cancel')} + + handleAction(index)}> + {confirmation.confirmText ?? t('common.confirm')} + + + + + ))} + + ) +} + +export default Confirmations diff --git a/src/components/UIProvider/index.ts b/src/components/UIProvider/index.ts new file mode 100644 index 0000000..dd383b1 --- /dev/null +++ b/src/components/UIProvider/index.ts @@ -0,0 +1 @@ +export { useConfirm, UIProvider } from './UIProvider' diff --git a/src/components/UIProvider/types.ts b/src/components/UIProvider/types.ts new file mode 100644 index 0000000..1493760 --- /dev/null +++ b/src/components/UIProvider/types.ts @@ -0,0 +1,9 @@ +export type ConfirmProperties = { + title: string + description?: string + confirmText?: string + cancelText?: string + onConfirm?: () => void + onCancel?: () => void + open?: boolean +} diff --git a/src/components/VersionSupport.tsx b/src/components/VersionSupport.tsx index b8074ac..8543b50 100644 --- a/src/components/VersionSupport.tsx +++ b/src/components/VersionSupport.tsx @@ -1,23 +1,24 @@ -/** @jsx jsx */ -import { jsx } from '@emotion/core' import React from 'react' -import { useVersionSupport } from '../hooks' +import { useVersionSupport } from '@/hooks/useVersionSupport' interface VersionSupportProps { - macos?: string - ios?: string + macos?: string | boolean + ios?: string | boolean + tvos?: string | boolean + children: React.ReactNode } const VersionSupport: React.FC = ({ macos, ios, + tvos, children, }) => { - const isSupported = useVersionSupport({ macos, ios }) + const isSupported = useVersionSupport({ macos, ios, tvos }) if (isSupported) { - return {children} + return <>{children} } return null diff --git a/src/components/VersionTag.tsx b/src/components/VersionTag.tsx new file mode 100644 index 0000000..a000e76 --- /dev/null +++ b/src/components/VersionTag.tsx @@ -0,0 +1,27 @@ +import React from 'react' + +import { usePlatform, usePlatformBuild, usePlatformVersion } from '@/store' + +const VersionTag = () => { + const platform = usePlatform() + const platformVersion = usePlatformVersion() + const platformBuild = usePlatformBuild() + + const isPlatformInfoShown = Boolean( + platform && platformBuild && platformVersion, + ) + + const content = isPlatformInfoShown + ? `v${process.env.REACT_APP_VERSION}` + + '\n' + + `${platform} v${platformVersion} (${platformBuild})` + : `v${process.env.REACT_APP_VERSION}` + + return ( + +
{content}
+
+ ) +} + +export default VersionTag diff --git a/src/components/VerticalSafeArea.tsx b/src/components/VerticalSafeArea.tsx new file mode 100644 index 0000000..6655584 --- /dev/null +++ b/src/components/VerticalSafeArea.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import { css } from '@emotion/react' + +export const BottomSafeArea = () => { + return ( +
+ ) +} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..e361dbd --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,143 @@ +import * as React from 'react' +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog' + +import { buttonVariants } from '@/components/ui/button' +import { cn } from '@/utils/shadcn' + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = ({ + className, + ...props +}: AlertDialogPrimitive.AlertDialogPortalProps) => ( + +) +AlertDialogPortal.displayName = AlertDialogPrimitive.Portal.displayName + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = 'AlertDialogHeader' + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = 'AlertDialogFooter' + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..226ad40 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/utils/shadcn' + +const alertVariants = cva( + 'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground', + { + variants: { + variant: { + default: 'bg-background text-foreground', + destructive: + 'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = 'Alert' + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = 'AlertTitle' + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = 'AlertDescription' + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..f5b8f9a --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' + +import { cn } from '@/utils/shadcn' + +const badgeVariants = cva( + 'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + { + variants: { + variant: { + default: + 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', + secondary: + 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80', + outline: 'text-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/button-group.tsx b/src/components/ui/button-group.tsx new file mode 100644 index 0000000..136008b --- /dev/null +++ b/src/components/ui/button-group.tsx @@ -0,0 +1,41 @@ +import * as React from 'react' +import { cva, VariantProps } from 'class-variance-authority' + +import { cn } from '@/utils/shadcn' + +const variants = cva('flex items-center space-x-3', { + variants: { + align: { + left: 'justify-start', + center: 'justify-center', + right: 'justify-end', + }, + }, + defaultVariants: { + align: 'left', + }, +}) + +type ButtonGroupProps = React.HTMLAttributes & + VariantProps + +const ButtonGroup = React.forwardRef( + ({ children, className, align, ...props }, ref) => ( +
+ {children} +
+ ), +) + +ButtonGroup.displayName = 'ButtonGroup' + +export { ButtonGroup } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..debec2a --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,102 @@ +import * as React from 'react' +import { css } from '@emotion/react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import { Loader2 } from 'lucide-react' +import tw from 'twin.macro' + +import { cn } from '@/utils/shadcn' + +const buttonVariants = cva( + 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: + 'bg-primary text-primary-foreground shadow hover:bg-primary/90', + destructive: + 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', + outline: + 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground', + secondary: + 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + stretch: { + true: 'w-full', + false: '', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean + isLoading?: boolean + loadingLabel?: string + stretch?: boolean +} + +const Button = React.forwardRef( + ( + { + className, + variant, + size, + asChild = false, + isLoading, + loadingLabel, + stretch, + ...props + }, + ref, + ) => { + const Comp = asChild ? Slot : 'button' + + if (isLoading) { + return ( + + + {loadingLabel} + + ) + } + + return ( + * { + ${tw`w-4 h-4`}; + } + `, + ]} + ref={ref} + {...props} + /> + ) + }, +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..462239e --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,73 @@ +import * as React from 'react' + +import { cn } from '@/utils/shadcn' + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = 'Card' + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = 'CardHeader' + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = 'CardTitle' + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = 'CardDescription' + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = 'CardContent' + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = 'CardFooter' + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..4bda691 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from 'react' +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { CheckIcon } from '@radix-ui/react-icons' + +import { cn } from '@/utils/shadcn' + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..c498bb6 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { Cross2Icon } from '@radix-ui/react-icons' + +import { cn } from '@/utils/shadcn' + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = ({ + className, + ...props +}: DialogPrimitive.DialogPortalProps) => ( + +) +DialogPortal.displayName = DialogPrimitive.Portal.displayName + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = 'DialogHeader' + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = 'DialogFooter' + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..6e786c4 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,203 @@ +import * as React from 'react' +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { + CheckIcon, + ChevronRightIcon, + DotFilledIcon, +} from '@radix-ui/react-icons' + +import { cn } from '@/utils/shadcn' + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..1756a00 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,176 @@ +import * as React from 'react' +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from 'react-hook-form' +import * as LabelPrimitive from '@radix-ui/react-label' +import { Slot } from '@radix-ui/react-slot' + +import { Label } from '@/components/ui/label' +import { cn } from '@/utils/shadcn' + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error('useFormField should be used within ') + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) +}) +FormItem.displayName = 'FormItem' + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( +