diff --git a/.env b/.env index aa13947..0a6cbcd 100644 --- a/.env +++ b/.env @@ -1,9 +1,6 @@ SKIP_PREFLIGHT_CHECK=true API_GENCOMMENT=/** @generated THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. */ -REACT_APP_API_URL=https://api.github.com/graphql -# Используется для кодогенерации, Apollo Client плагина -REACT_APP_ACCESS_TOKEN=9221b1b5ba4b0de72eed80b09602ed7b4c73c222 # Firebase section REACT_APP_FIREBASE_apiKey=AIzaSyABlBQc-tjCRKWwBj8jTTrMiT2M2UKiJpk @@ -15,3 +12,12 @@ REACT_APP_FIREBASE_messagingSenderId=14406286286 REACT_APP_FIREBASE_appId=1:14406286286:web:58c7c11c2762d36a55c99f REACT_APP_DEV_STORAGE_URL=https://dev.github-client.gq/dev/temp-stands.html + +# Github links +REACT_APP_API_URL=https://api.github.com/graphql +REACT_APP_GITHUB_DOMAIN=https://github.com/ +REACT_APP_GITHUB_MAIN=https://github.com/ani-team/github-client +REACT_APP_GITHUB_FEEDBACK=https://github.com/ani-team/github-client/issues/new + +# Используется для кодогенерации, Apollo Client плагина +REACT_APP_ACCESS_TOKEN=8709f2ca2f263a81f1de65511485c97c70f653f0 diff --git a/.eslintignore b/.eslintignore index c39cafd..b971172 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,3 +3,5 @@ dist/** build/** *.gen.ts .github +public/ +.workflows diff --git a/.eslintrc.js b/.eslintrc.js index 5e37602..c86d615 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,3 +1,27 @@ +/** Разрешенные импорты (с публичными API) */ +const ALLOWED_PATH_GROUPS = ["shared", "shared/**", "pages", "features", "models"].map( + (pattern) => ({ + pattern, + group: "internal", + position: "after", + }), +); +/** Для запрета приватных путей */ +const DENIED_PATH_GROUPS = [ + // Private imports are prohibited, use public imports instead + "app/**", + "pages/**", + "features/**", + "shared/*/**", + "models.gen", + // Prefer absolute imports instead of relatives (for root modules) + "../**/app", + "../**/pages", + "../**/features", + "../**/shared", + "../**/models", +]; + module.exports = { parser: "@typescript-eslint/parser", parserOptions: { @@ -12,7 +36,7 @@ module.exports = { browser: true, es6: true, }, - plugins: ["react", "@typescript-eslint", "@graphql-eslint"], + plugins: ["react", "@typescript-eslint", "@graphql-eslint", "unicorn"], extends: [ "react-app", "eslint:recommended", @@ -25,29 +49,50 @@ module.exports = { "prettier/react", ], rules: { + // imports "import/first": 2, "import/no-unresolved": 0, "import/order": [ 2, { - pathGroups: [ - "shared", - "shared/**", - "pages", - "pages/**", - "features", - "features/**", - "models", - ].map((pattern) => ({ - pattern, - group: "internal", - position: "after", - })), + pathGroups: ALLOWED_PATH_GROUPS, // TODO: Добавить сортировку `import "./index.scss";` (располагать внизу) + // TODO: Добавить сортировку `import *** from "react"` (располагать вверху) pathGroupsExcludedImportTypes: ["builtin"], groups: ["builtin", "external", "internal", "parent", "sibling", "index"], }, ], + // TODO: specify message: ("Please use allowed public API (not private imports!)") + "no-restricted-imports": [2, { patterns: DENIED_PATH_GROUPS }], + // variables + "prefer-const": 2, + "no-var": 2, + // base + "camelcase": [1, { ignoreDestructuring: true, ignoreImports: true, properties: "never" }], + "no-else-return": 2, + "max-len": [1, { code: 120 }], + "dot-notation": 2, + "eol-last": 2, + // alert, console + "no-alert": 2, + "no-console": 0, + // equals + "eqeqeq": 1, + "no-eq-null": 2, + // function + "max-params": [1, 2], + "max-lines-per-function": [1, 48], + "arrow-parens": [2, "always"], + // plugin:unicorn + "unicorn/no-for-loop": 2, + "unicorn/no-abusive-eslint-disable": 2, + "unicorn/no-array-instanceof": 2, + "unicorn/no-zero-fractions": 2, + "unicorn/prefer-includes": 2, + "unicorn/prefer-text-content": 2, + "unicorn/import-index": 2, + "unicorn/throw-new-error": 2, + // plugin:graphql "@graphql-eslint/no-anonymous-operations": 2, }, overrides: [ @@ -57,6 +102,7 @@ module.exports = { plugins: ["@graphql-eslint"], rules: { "prettier/prettier": 0, + "max-len": 0, }, }, ], diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index 725f8ae..baa835d 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -1,7 +1,7 @@ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions -name: Production CI/CD +name: CI on: push: diff --git a/README.md b/README.md index 13e4529..34eb98f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,16 @@ # github-client +![Version](https://img.shields.io/github/package-json/v/ani-team/github-client) +![CI](https://github.com/niyazm524/github-client/workflows/CI/badge.svg?branch=master) +![Website](https://img.shields.io/website?down_message=offline&up_message=online&url=https%3A%2F%2Fgithub-client.gq) +[![Hits](https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Fani-team%2Fgithub-client&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false)](https://hits.seeyoufarm.com) +![GitHub top language](https://img.shields.io/github/languages/top/niyazm524/github-client) +![GitHub commit activity](https://img.shields.io/github/commit-activity/w/ani-team/github-client) + favicon +> Powered by [Feature Driven Development](https://www.notion.so/Feature-Driven-Development-dfe306d664ae4780bcf999ccdd15e532 "Entire app was designed and builded with FDD core conceptions") + GitHub client within the *React Akvelon 2020* course. [wiki]: https://github.com/martis-git/github-client/wiki @@ -22,16 +31,18 @@ GitHub client within the *React Akvelon 2020* course. - See repo/collabs list and details of [any user](https://github-client.gq/gaearon) -- See base info of [any public repository](https://github-client.gq/facebook/react) with [branches base manipulating](https://github-client.gq/facebook/react/tree/17.0.0-dev) +- See base info and stats of [any public repository](https://github-client.gq/facebook/react) with [branches base manipulating](https://github-client.gq/facebook/react/tree/17.0.0-dev) - Use search by [repositories](https://github-client.gq/search?o=desc&q=react&s=stars)/[users](https://github-client.gq/search?o=desc&q=google&s=repositories&type=users) with sorting -- View corresponding page on github through origin button! +- Try our end-to-end routing with Github with origin button, and specific adaptations on every page! - Connect with your account safely - [by Github OAuth](https://github-client.gq/auth) - [Get feedback](https://github-client.gq/some-unexisting-route-but-we-have-error-parking-page) if some errors occurred +- Try our base interactivity on [UserPage](https://github-client.gq/gaearon) - following, starring +- Get the best UX with our **loading && placeholder view logic** - Try [**github-client right now**](https://github-client.gq) or [last dev version (but unstable)](https://dev.github-client.gq) =} > If you found issues or have ideas for service - please, [share with us](https://github.com/ani-team/github-client/issues/new) 🔥 -screen +![screen](docs/search.png) ## Technology stack - **UI**: `react`, `antd`, `classnames`, `tailwindcss` @@ -39,9 +50,9 @@ GitHub client within the *React Akvelon 2020* course. - **Fetching**: `graphql`, `apollo-client (3+)` - **API Codegen**: `graphql-codegen` - **Routing**: `react-router` -- Tests: `eslint`, `prettier`, `graphql-eslint`, `stylelint` +- **Tests**: `eslint`, `prettier`, `graphql-eslint`, `stylelint` - **Auth**: `GitHub OAuth`, `firebase` -- CI/CD: `github-actions`, `firebase` +- **CI/CD**: `github-actions`, `firebase`
react @@ -49,10 +60,10 @@ GitHub client within the *React Akvelon 2020* course. antdesign graphql apollo -eslint +eslint prettier stylelint github -github-actions +github-actions firebase
diff --git a/docs/search.png b/docs/search.png index 7254335..aad6984 100644 Binary files a/docs/search.png and b/docs/search.png differ diff --git a/package-lock.json b/package-lock.json index 7699a8c..f0cd8ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "github-client", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 1, "requires": true, "dependencies": { @@ -6383,6 +6383,15 @@ } } }, + "clean-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", + "integrity": "sha1-jffHquUf02h06PjQW5GAvBGj/tc=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, "clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -8317,6 +8326,16 @@ } } }, + "eslint-ast-utils": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/eslint-ast-utils/-/eslint-ast-utils-1.1.0.tgz", + "integrity": "sha512-otzzTim2/1+lVrlH19EfQQJEhVJSu0zOb9ygb3iapN6UlyaDtyRq4b5U1FuW0v1lRa9Fp/GJyHkSwm6NqABgCA==", + "dev": true, + "requires": { + "lodash.get": "^4.4.2", + "lodash.zip": "^4.2.0" + } + }, "eslint-config-prettier": { "version": "6.13.0", "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-6.13.0.tgz", @@ -8662,6 +8681,133 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-1.7.0.tgz", "integrity": "sha512-iXTCFcOmlWvw4+TOE8CLWj6yX1GwzT0Y6cUfHHZqWnSk144VmVIRcVGtUAzrLES7C798lmvnt02C7rxaOX1HNA==" }, + "eslint-plugin-unicorn": { + "version": "23.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-unicorn/-/eslint-plugin-unicorn-23.0.0.tgz", + "integrity": "sha512-Vabo3cjl6cjyhcf+76CdQEY6suOFzK0Xh3xo0uL9VDYrDJP5+B6PjV0tHTYm82WZmFWniugFJM3ywHSNYTi/ZQ==", + "dev": true, + "requires": { + "ci-info": "^2.0.0", + "clean-regexp": "^1.0.0", + "eslint-ast-utils": "^1.1.0", + "eslint-template-visitor": "^2.2.1", + "eslint-utils": "^2.1.0", + "import-modules": "^2.0.0", + "lodash": "^4.17.20", + "pluralize": "^8.0.0", + "read-pkg-up": "^7.0.1", + "regexp-tree": "^0.1.21", + "reserved-words": "^0.1.2", + "safe-regex": "^2.1.1", + "semver": "^7.3.2" + }, + "dependencies": { + "find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "requires": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + } + }, + "locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "requires": { + "p-locate": "^4.1.0" + } + }, + "lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "requires": { + "yallist": "^4.0.0" + } + }, + "p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "requires": { + "p-limit": "^2.2.0" + } + }, + "parse-json": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.1.0.tgz", + "integrity": "sha512-+mi/lmVVNKFNVyLXV31ERiy2CY5E1/F6QtJFEzoChPRwwngMNXRDQ9GJ5WdE2Z2P4AujsOi0/+2qHID68KwfIQ==", + "dev": true, + "requires": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + } + }, + "path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true + }, + "read-pkg": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", + "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", + "dev": true, + "requires": { + "@types/normalize-package-data": "^2.4.0", + "normalize-package-data": "^2.5.0", + "parse-json": "^5.0.0", + "type-fest": "^0.6.0" + }, + "dependencies": { + "type-fest": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", + "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", + "dev": true + } + } + }, + "read-pkg-up": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", + "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", + "dev": true, + "requires": { + "find-up": "^4.1.0", + "read-pkg": "^5.2.0", + "type-fest": "^0.8.1" + } + }, + "safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "requires": { + "regexp-tree": "~0.1.1" + } + }, + "semver": { + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.4.tgz", + "integrity": "sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==", + "dev": true, + "requires": { + "lru-cache": "^6.0.0" + } + } + } + }, "eslint-scope": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", @@ -8671,6 +8817,18 @@ "estraverse": "^4.1.1" } }, + "eslint-template-visitor": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/eslint-template-visitor/-/eslint-template-visitor-2.2.1.tgz", + "integrity": "sha512-q3SxoBXz0XjPGkUpwGVAwIwIPIxzCAJX1uwfVc8tW3v7u/zS7WXNH3I2Mu2MDz2NgSITAyKLRaQFPHu/iyKxDQ==", + "dev": true, + "requires": { + "babel-eslint": "^10.1.0", + "eslint-visitor-keys": "^1.3.0", + "esquery": "^1.3.1", + "multimap": "^1.1.0" + } + }, "eslint-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-2.1.0.tgz", @@ -10815,6 +10973,12 @@ "resolve-cwd": "^2.0.0" } }, + "import-modules": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/import-modules/-/import-modules-2.0.0.tgz", + "integrity": "sha512-iczM/v9drffdNnABOKwj0f9G3cFDon99VcG1mxeBsdqnbd+vnQ5c2uAiCHNQITqFTOPaEvwg3VjoWCur0uHLEw==", + "dev": true + }, "imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -13133,6 +13297,12 @@ "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", "integrity": "sha1-0CJTc662Uq3BvILklFM5qEJ1R3M=" }, + "lodash.zip": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.zip/-/lodash.zip-4.2.0.tgz", + "integrity": "sha1-7GZi5IlkCO1KtsVCo5kLcswIACA=", + "dev": true + }, "log-symbols": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.0.0.tgz", @@ -13281,8 +13451,7 @@ "longest-streak": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz", - "integrity": "sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==", - "dev": true + "integrity": "sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==" }, "loose-envify": { "version": "1.4.0", @@ -13407,7 +13576,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", - "dev": true, "requires": { "repeat-string": "^1.0.0" } @@ -13463,6 +13631,67 @@ "parse-entities": "^2.0.0" } }, + "mdast-util-gfm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-0.1.0.tgz", + "integrity": "sha512-HLfygQL6HdhJhFbLta4Ki9hClrzyAxRjyRvpm5caN65QZL+NyHPmqFlnF9vm1Rn58JT2+AbLwNcEDY4MEvkk8Q==", + "requires": { + "mdast-util-gfm-autolink-literal": "^0.1.0", + "mdast-util-gfm-strikethrough": "^0.2.0", + "mdast-util-gfm-table": "^0.1.0", + "mdast-util-gfm-task-list-item": "^0.1.0" + } + }, + "mdast-util-gfm-autolink-literal": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-0.1.1.tgz", + "integrity": "sha512-gJ2xSpqKCetSr22GEWpZH3f5ffb4pPn/72m4piY0v7T/S+O7n7rw+sfoPLhb2b4O7WdnERoYdALRcmD68FMtlw==" + }, + "mdast-util-gfm-strikethrough": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-0.2.2.tgz", + "integrity": "sha512-T37ZbaokJcRbHROXmoVAieWnesPD5N21tv2ifYzaGRLbkh1gknItUGhZzHefUn5Zc/eaO/iTDSAFOBrn/E8kWw==", + "requires": { + "mdast-util-to-markdown": "^0.5.0" + } + }, + "mdast-util-gfm-table": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-0.1.4.tgz", + "integrity": "sha512-T4xFSON9kUb/IpYA5N+KGWcsdGczAvILvKiXQwUGind6V9fvjPCR9yhZnIeaLdBWXaz3m/Gq77ZtuLMjtFR4IQ==", + "requires": { + "markdown-table": "^2.0.0", + "mdast-util-to-markdown": "^0.5.0" + } + }, + "mdast-util-gfm-task-list-item": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-0.1.5.tgz", + "integrity": "sha512-6O0bt34r+e7kYjeSwedhjDPYraspKIYKbhvhQEEioL7gSmXDxhN7WQW2KoxhVMpNzjNc03yC7K5KH6NHlz2jOA==", + "requires": { + "mdast-util-to-markdown": "^0.5.0" + } + }, + "mdast-util-to-markdown": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-0.5.4.tgz", + "integrity": "sha512-0jQTkbWYx0HdEA/h++7faebJWr5JyBoBeiRf0u3F4F3QtnyyGaWIsOwo749kRb1ttKrLLr+wRtOkfou9yB0p6A==", + "requires": { + "@types/unist": "^2.0.0", + "longest-streak": "^2.0.0", + "mdast-util-to-string": "^2.0.0", + "parse-entities": "^2.0.0", + "repeat-string": "^1.0.0", + "zwitch": "^1.0.0" + }, + "dependencies": { + "mdast-util-to-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz", + "integrity": "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==" + } + } + }, "mdast-util-to-string": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-1.1.0.tgz", @@ -13678,6 +13907,56 @@ "parse-entities": "^2.0.0" } }, + "micromark-extension-gfm": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-0.3.1.tgz", + "integrity": "sha512-lJlhcOqzoJdjQg+LMumVHdUQ61LjtqGdmZtrAdfvatRUnJTqZlRwXXHdLQgNDYlFw4mycZ4NSTKlya5QcQXl1A==", + "requires": { + "micromark": "~2.10.0", + "micromark-extension-gfm-autolink-literal": "~0.5.0", + "micromark-extension-gfm-strikethrough": "~0.6.0", + "micromark-extension-gfm-table": "~0.4.0", + "micromark-extension-gfm-tagfilter": "~0.3.0", + "micromark-extension-gfm-task-list-item": "~0.3.0" + } + }, + "micromark-extension-gfm-autolink-literal": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-0.5.1.tgz", + "integrity": "sha512-j30923tDp0faCNDjwqe4cMi+slegbGfc3VEAExEU8d54Q/F6pR6YxCVH+6xV0ItRoj3lCn1XkUWcy6FC3S9BOw==", + "requires": { + "micromark": "~2.10.0" + } + }, + "micromark-extension-gfm-strikethrough": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-0.6.2.tgz", + "integrity": "sha512-aehEEqtTn3JekJNwZZxa7ZJVfzmuaWp4ew6x6sl3VAKIwdDZdqYeYSQIrNKwNgH7hX0g56fAwnSDLusJggjlCQ==", + "requires": { + "micromark": "~2.10.0" + } + }, + "micromark-extension-gfm-table": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-0.4.1.tgz", + "integrity": "sha512-xVpqOnfFaa2OtC/Y7rlt4tdVFlUHdoLH3RXAZgb/KP3DDyKsAOx6BRS3UxiiyvmD/p2l6VUpD4bMIniuP4o4JA==", + "requires": { + "micromark": "~2.10.0" + } + }, + "micromark-extension-gfm-tagfilter": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-0.3.0.tgz", + "integrity": "sha512-9GU0xBatryXifL//FJH+tAZ6i240xQuFrSL7mYi8f4oZSbc+NvXjkrHemeYP0+L4ZUT+Ptz3b95zhUZnMtoi/Q==" + }, + "micromark-extension-gfm-task-list-item": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-0.3.2.tgz", + "integrity": "sha512-cm8lYS10YAqeXE9B27TK3u1Ihumo3H9p/3XumT+jp8vSuSbSpFIJe0bDi2kq4YAAIxtcTzUOxhEH4ko2/NYDkQ==", + "requires": { + "micromark": "~2.10.0" + } + }, "micromatch": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", @@ -13967,6 +14246,12 @@ "resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz", "integrity": "sha1-iZ8R2WhuXgXLkbNdXw5jt3PPyQE=" }, + "multimap": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/multimap/-/multimap-1.1.0.tgz", + "integrity": "sha512-0ZIR9PasPxGXmRsEF8jsDzndzHDj7tIav+JUmvIFB/WHswliFnquxECT/De7GR4yg99ky/NlRKJT82G1y271bw==", + "dev": true + }, "mute-stream": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz", @@ -15087,6 +15372,12 @@ "semver-compare": "^1.0.0" } }, + "pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true + }, "pn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz", @@ -17587,6 +17878,12 @@ "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.2.10.tgz", "integrity": "sha512-8t6074A68gHfU8Neftl0Le6KTDwfGAj7IyjPIMSfikI2wJUTHDMaIq42bUsfVnj8mhx0R+45rdUXHGpN164avA==" }, + "regexp-tree": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.21.tgz", + "integrity": "sha512-kUUXjX4AnqnR8KRTCrayAo9PzYMRKmVoGgaz2tBuz0MF3g1ZbGebmtW0yFHfFK9CmBjQKeYIgoL22pFLBJY7sw==", + "dev": true + }, "regexp.prototype.flags": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz", @@ -17867,6 +18164,15 @@ "unified": "^9.0.0" } }, + "remark-gfm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-1.0.0.tgz", + "integrity": "sha512-KfexHJCiqvrdBZVbQ6RopMZGwaXz6wFJEfByIuEwGf0arvITHjiKKZ1dpXujjH9KZdm1//XJQwgfnJ3lmXaDPA==", + "requires": { + "mdast-util-gfm": "^0.1.0", + "micromark-extension-gfm": "^0.3.0" + } + }, "remark-parse": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-8.0.3.tgz", @@ -18071,6 +18377,12 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" }, + "reserved-words": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/reserved-words/-/reserved-words-0.1.2.tgz", + "integrity": "sha1-AKCUD5jNUBrqqsMWQR2a3FKzGrE=", + "dev": true + }, "resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -22196,6 +22508,11 @@ "version": "0.8.15", "resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz", "integrity": "sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==" + }, + "zwitch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", + "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==" } } } diff --git a/package.json b/package.json index b784946..3d6994b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "github-client", - "version": "0.1.0", + "version": "0.2.0", "private": true, "dependencies": { "@ant-design/icons": "^4.2.2", @@ -28,6 +28,7 @@ "react-router-dom": "^5.2.0", "react-scripts": "3.4.3", "react-syntax-highlighter": "^15.3.1", + "remark-gfm": "^1.0.0", "tailwindcss": "^1.9.5", "typescript": "^3.7.5", "use-query-params": "^1.1.9" @@ -76,6 +77,7 @@ "eslint": "^7.11.0", "eslint-config-prettier": "^6.13.0", "eslint-plugin-prettier": "^3.1.4", + "eslint-plugin-unicorn": "^23.0.0", "husky": "^4.3.0", "lint-staged": "^10.4.2", "prettier": "^2.1.2", diff --git a/src/.deploy/index.ts b/src/.deploy/index.ts index 4f3eedb..8443585 100644 --- a/src/.deploy/index.ts +++ b/src/.deploy/index.ts @@ -1 +1,9 @@ +/** + * Модуль для облегченной реализации для temp-стендов на firebase (guest mode) + * @remark + * - Связано с тем, что сложно реализовать полноценную авторизацию для temp-стендов, разворачиваемых + * Во время pull-requests + * - Модуль нужен только для разработки - во время основной работы приложения же - не используется + */ + export * from "./temp-stand"; diff --git a/src/.deploy/temp-stand.ts b/src/.deploy/temp-stand.ts index 2dfa001..a19bdd6 100644 --- a/src/.deploy/temp-stand.ts +++ b/src/.deploy/temp-stand.ts @@ -1,5 +1,6 @@ -import { CREDENTIAL_KEY } from "features/auth"; +import { Auth } from "features"; +const { CREDENTIAL_KEY } = Auth; const tempStandRegex = /^(github-client-47c49|dev-github-client)--pr\d+.+\.web\.app$/; const isTempStand = () => tempStandRegex.test(window.location.host); @@ -7,6 +8,9 @@ const isTempStand = () => tempStandRegex.test(window.location.host); const STAND_URL_ENV = "REACT_APP_DEV_STORAGE_URL"; const devStorageUrl = process.env[STAND_URL_ENV]; +/** + * Инициализация гостевого режима с псевдо-авторизацией + */ export const loadLocalStorageFromDevIfNeeded = async () => { if (!isTempStand() || !devStorageUrl) { if(!devStorageUrl) { diff --git a/src/app/error-handling/error-catcher.tsx b/src/app/error-handling/error-catcher.tsx deleted file mode 100644 index b3880d0..0000000 --- a/src/app/error-handling/error-catcher.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React, { useEffect, useState, ReactNode } from "react"; -import { ServerError, useApolloClient } from "@apollo/client"; -import { onError } from "@apollo/client/link/error"; -import { GraphQLError } from "graphql"; -import { useLocation } from "react-router"; -import { AppError } from "models"; -import { ErrorDefinitions } from "./error-definitions"; - -const isGithubError = (error: any): error is { type: string } => { - return typeof error.type === "string"; -}; - -const isServerError = (error: any): error is ServerError => { - return typeof error.statusCode === "number"; -}; - -function mapError(error: GraphQLError | Error | ServerError | undefined): AppError | null { - if (!error) return null; - if (isGithubError(error)) { - // FIXME: handle 403 and 500 errors as well w/o side effects - if (error.type === "NOT_FOUND") { - return ErrorDefinitions[error.type]; - } - } - if (isServerError(error)) { - if (error.statusCode === 401) return ErrorDefinitions.UNAUTHORIZED; - } - // TODO: handle network errors and whatever can be broken - return null; -} - -type Props = PropsWithChildren<{ - handler: (props: { error: AppError }) => ReactNode; -}>; - -export default function ErrorCatcher({ handler, children }: Props) { - const apolloClient = useApolloClient(); - const location = useLocation(); - const [error, setError] = useState(null); - - useEffect(() => { - const errorLink = onError(({ graphQLErrors, networkError }) => { - setError(mapError(graphQLErrors?.[0] || networkError)); - }); - apolloClient.setLink(errorLink.concat(apolloClient.link)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => setError(null), [location]); - - if (error) { - return <>{handler({ error })}; - } - return <>{children}; -} diff --git a/src/app/error-handling/index.ts b/src/app/error-handling/index.ts deleted file mode 100644 index 6bf79c6..0000000 --- a/src/app/error-handling/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { default as ErrorCatcher } from "./error-catcher"; -export { ErrorDefinitions } from "./error-definitions"; diff --git a/src/app/header/hooks.ts b/src/app/header/hooks.ts new file mode 100644 index 0000000..74d9d27 --- /dev/null +++ b/src/app/header/hooks.ts @@ -0,0 +1,39 @@ +import { KeyboardEventHandler } from "react"; +import { StringParam, useQueryParams } from "use-query-params"; +import { useHistory, useLocation } from "react-router-dom"; +import * as qs from "query-string"; + +// FIXME: get from `pages`? +const SEARCH_URL = "/search"; + +/** + * @hook Логика обработки инпута поиска + */ +export const useSearchInput = () => { + // !!! FIXME: limit scope of query-params literals + const [query] = useQueryParams({ + q: StringParam, + type: StringParam, + s: StringParam, + o: StringParam, + }); + const location = useLocation(); + const history = useHistory(); + + /** + * Обработка инпута поиска + */ + const handleKeyDown: KeyboardEventHandler = ({ key, currentTarget }) => { + if (key === "Enter" && currentTarget.value) { + const q = currentTarget.value; + history.push(`${SEARCH_URL}?${qs.stringify({ ...query, q })}`); + } + }; + /** + * Поисковой запрос + * @remark Если не страница поиска - обнуляем инпут + */ + const searchValue = location.pathname === SEARCH_URL ? query.q ?? "" : ""; + + return { handleKeyDown, searchValue }; +}; diff --git a/src/app/header/index.tsx b/src/app/header/index.tsx index 1dc0533..df42a15 100644 --- a/src/app/header/index.tsx +++ b/src/app/header/index.tsx @@ -1,26 +1,19 @@ import React from "react"; import { Layout, Input } from "antd"; -import { Link, useHistory, useLocation } from "react-router-dom"; -import { StringParam, useQueryParams } from "use-query-params"; -import * as qs from "query-string"; +import { Link } from "react-router-dom"; +import { GITHUB_MAIN, GITHUB_FEEDBACK } from "shared/get-env"; import { Auth } from "features"; import { ReactComponent as IcLogo } from "./logo.svg"; +import { useSearchInput } from "./hooks"; import "./index.scss"; -const FEEDBACK_URL = "https://github.com/ani-team/github-client/issues/new"; -const GITHUB_URL = "https://github.com/ani-team/github-client"; - +/** + * Хедер приложения + * @remark Содержит поисковой инпут с базовой логикой + */ const Header = () => { const { isAuth } = Auth.useAuth(); - // !!! FIXME: limit scope of query-params literals - const [query] = useQueryParams({ - q: StringParam, - type: StringParam, - s: StringParam, - o: StringParam, - }); - const location = useLocation(); - const history = useHistory(); + const { handleKeyDown, searchValue } = useSearchInput(); return ( @@ -33,25 +26,24 @@ const Header = () => { { - if (key === "Enter" && currentTarget.value) { - history.push( - `/search?${qs.stringify({ - q: currentTarget.value, - type: query.type, - s: query.s, - o: query.o, - })}`, - ); - } - }} + defaultValue={searchValue} + onKeyDown={handleKeyDown} /> )} - + GitHub - + Feedback diff --git a/src/app/hocs/index.ts b/src/app/hocs/index.ts index dd24d11..11c1626 100644 --- a/src/app/hocs/index.ts +++ b/src/app/hocs/index.ts @@ -1,5 +1,15 @@ +import { compose } from "shared/helpers"; import withApollo from "./with-apollo"; import withRouter from "./with-router"; +import withAntd from "./with-antd"; -// Потом какой-нибудь `compose` метод заинсталлим откуда-нить и покрасивше будет -export const withHocs = (component: () => JSX.Element) => withRouter(withApollo(component)); +/** + * @hoc Инициализирующая логика приложения + * @remark Содержит: + * - логику инициализации antd (withAntd) + * - логику подключения к API (withApollo) + * - логику инициализации роутера (withRouter) + */ +export const withHocs = compose(withAntd, withRouter, withApollo); + +export { ErrorHandlingProvider } from "./with-error-handling"; diff --git a/src/app/hocs/with-antd.tsx b/src/app/hocs/with-antd.tsx new file mode 100644 index 0000000..c6549e9 --- /dev/null +++ b/src/app/hocs/with-antd.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { ConfigProvider } from "antd"; + +// eslint-disable-next-line max-len +// https://ant.design/docs/react/faq#How-do-I-prevent-Select-Dropdown-DatePicker-TimePicker-Popover-Popconfirm-scrolling-with-the-page + +/** + * @hoc Инициализация antd для корректного использования компонентов + * @remark Для попапов в системе - возвращаем parentElement (для фикса бага) или document.body (defaultValue) + * @see https://github.com/ani-team/github-client/issues/162#issuecomment-736676022 + */ +const withAntd = (component: Component) => () => ( + parentElement || document.body}> + {component()} + +); + +export default withAntd; diff --git a/src/app/hocs/with-apollo.tsx b/src/app/hocs/with-apollo.tsx index df14ec9..4bfecb0 100644 --- a/src/app/hocs/with-apollo.tsx +++ b/src/app/hocs/with-apollo.tsx @@ -4,12 +4,17 @@ import { setContext } from "@apollo/client/link/context"; import { API_URL } from "shared/get-env"; import { Auth } from "features"; -// TODO: Уточнить, нужно ли дополнительно задавать контекст - +/** + * Инициализация API.baseUrl + */ const httpLink = createHttpLink({ uri: API_URL, }); +/** + * Логика авторизации + * FIXME: вынести в features/auth? + */ const authLink = setContext((_, { headers }) => { // get the authentication token from local storage if it exists const token = Auth.getToken(); @@ -23,6 +28,7 @@ const authLink = setContext((_, { headers }) => { }); /** + * Инициализация инстанса клиента * @see https://www.apollographql.com/docs/react/networking/authentication/ */ const client = new ApolloClient({ @@ -32,9 +38,9 @@ const client = new ApolloClient({ }); /** - * Обертка для подключения и работы с API + * @hoc Инициализация подключения apollo для работы с API */ -const withApollo = (component: () => JSX.Element) => () => ( +const withApollo = (component: Component) => () => ( {component()} ); diff --git a/src/app/hocs/with-error-handling.tsx b/src/app/hocs/with-error-handling.tsx new file mode 100644 index 0000000..e4b989e --- /dev/null +++ b/src/app/hocs/with-error-handling.tsx @@ -0,0 +1,21 @@ +import React, { lazy } from "react"; +import { alert } from "shared/helpers"; +import { Error } from "features"; +import { AppError } from "models"; + +const ErrorPage = lazy(() => import("pages/error")); + +/** + * @hoc Инициализация отлова ошибок + */ +export const ErrorHandlingProvider = ({ children }: PropsWithChildren) => { + const onNetworkError = ({ message, description }: AppError) => alert.warn(message, description); + return ( + } + > + {children} + + ); +}; diff --git a/src/app/hocs/with-router.tsx b/src/app/hocs/with-router.tsx index f660860..0f7bdbb 100644 --- a/src/app/hocs/with-router.tsx +++ b/src/app/hocs/with-router.tsx @@ -4,9 +4,9 @@ import { BrowserRouter, Route } from "react-router-dom"; import { QueryParamProvider } from "use-query-params"; /** - * Инициализация роутера с провайдером для работы с get-параметрами + * @hoc Инициализация роутера с провайдером для работы с get-параметрами */ -const withRouter = (component: () => JSX.Element) => () => ( +const withRouter = (component: Component) => () => ( }> {component()} diff --git a/src/app/index.scss b/src/app/index.scss index 5bd1364..06d215c 100644 --- a/src/app/index.scss +++ b/src/app/index.scss @@ -1,15 +1,17 @@ // NOTE: Временно используем вспомогательные utilities-классы, на время разработки прототипа приложения @import "~tailwindcss/dist/utilities.css"; -@import "./vars.scss"; -@import "./normalize.scss"; -@import "./normalize-antd.scss"; -@import "./utils.scss"; +// css-переменные проекта +@import "./styles/vars.scss"; +// нормализация стилей проекта +@import "./styles/normalize.scss"; +// нормализация стилей antd компонентов +@import "./styles/normalize-antd.scss"; +// классы-утилиты +@import "./styles/utils.scss"; .gc-app { display: flex; flex-direction: column; - // !!! FIXME: temp (caus #86) - overflow: overlay; font-family: var(--ff-primary); background-color: var(--clr-background); @@ -23,5 +25,10 @@ .ant-layout { background-color: unset; + + &-content { + // FIXME: deprecated (find better solution) + overflow: overlay; + } } } diff --git a/src/app/index.tsx b/src/app/index.tsx index 1e23703..30a6f02 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -1,16 +1,14 @@ -import React, { lazy } from "react"; +import React from "react"; import { Layout } from "antd"; import Routing from "pages"; -import { ErrorCatcher } from "./error-handling"; import Header from "./header"; -import { withHocs } from "./hocs"; +import { withHocs, ErrorHandlingProvider } from "./hocs"; import "./index.scss"; -// !!! FIXME: manage access -const ErrorPage = lazy(() => import("pages/error")); /** * Entry-point приложения - * @remark Содержит в HOC-обертке логику подключения к API (apollo) + * @remark Содержит в HOC-обертке инициализирующую логику приложения + * @see withHocs */ const App = () => { return ( @@ -18,9 +16,9 @@ const App = () => {
- }> + - + diff --git a/src/app/normalize-antd.scss b/src/app/styles/normalize-antd.scss similarity index 100% rename from src/app/normalize-antd.scss rename to src/app/styles/normalize-antd.scss diff --git a/src/app/normalize.scss b/src/app/styles/normalize.scss similarity index 100% rename from src/app/normalize.scss rename to src/app/styles/normalize.scss diff --git a/src/app/utils.scss b/src/app/styles/utils.scss similarity index 100% rename from src/app/utils.scss rename to src/app/styles/utils.scss diff --git a/src/app/vars.scss b/src/app/styles/vars.scss similarity index 100% rename from src/app/vars.scss rename to src/app/styles/vars.scss diff --git a/src/features/auth/consts.ts b/src/features/auth/consts.ts index c160e28..ce505ce 100644 --- a/src/features/auth/consts.ts +++ b/src/features/auth/consts.ts @@ -3,12 +3,15 @@ import { UserCredential } from "./types"; /** @localStorage Учетные данные */ export const CREDENTIAL_KEY = "GITHUB-CLIENT__CREDENTIAL"; +/** @localStorage Получение учетных данных */ export const getCredential = () => { return JSON.parse(localStorage.getItem(CREDENTIAL_KEY) || "") as UserCredential; }; +/** @localStorage Получение токена доступа */ export const getToken = () => getCredential().accessToken; +/** Базовые роуты модуля авторизации */ export const routes = { main: "/", logout: "/", diff --git a/src/features/auth/firebase/auth-github.ts b/src/features/auth/firebase/auth-github.ts index a275f56..13e85fb 100644 --- a/src/features/auth/firebase/auth-github.ts +++ b/src/features/auth/firebase/auth-github.ts @@ -1,6 +1,10 @@ import { AuthContext } from "../types"; import firebase from "./init"; +/** + * Проверка респонса от сервиса во время авторизации + * @remark Минимально нужные поля для продолжения работы в системе + */ const isValidAuthContext = (ctx: any): ctx is AuthContext => { return ( typeof ctx?.credential?.accessToken === "string" && @@ -8,7 +12,11 @@ const isValidAuthContext = (ctx: any): ctx is AuthContext => { ); }; -export default function authGithub() { +/** + * Авторизация Github OAuth через firebase + * @remark Стоит использовать только на странице Авторизации!!! + */ +function authGithub() { const provider = new firebase.auth.GithubAuthProvider(); provider.addScope("repo"); provider.addScope("user:follow"); @@ -29,3 +37,5 @@ export default function authGithub() { }; }); } + +export default authGithub; diff --git a/src/features/auth/firebase/init.ts b/src/features/auth/firebase/init.ts index 4cf1ac0..146ca42 100644 --- a/src/features/auth/firebase/init.ts +++ b/src/features/auth/firebase/init.ts @@ -2,6 +2,9 @@ import firebase from "firebase/app"; import "firebase/auth"; import { firebaseConfig } from "shared/get-env"; +/** + * Инициализация firebase приложения + */ firebase.initializeApp(firebaseConfig); export default firebase; diff --git a/src/features/auth/hooks.ts b/src/features/auth/hooks.ts index ae0197e..3cf1a8d 100644 --- a/src/features/auth/hooks.ts +++ b/src/features/auth/hooks.ts @@ -2,6 +2,9 @@ import { useLocalStorage } from "shared/hooks"; import { CREDENTIAL_KEY } from "./consts"; import { UserCredential } from "./types"; +/** + * @hook Использование контекста авторизации и соответствующих методов + */ export const useAuth = () => { const [viewer, setViewer] = useLocalStorage(CREDENTIAL_KEY, null); const isAuth = !!viewer; diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts index 2b5a032..b88fd80 100644 --- a/src/features/auth/index.ts +++ b/src/features/auth/index.ts @@ -1,5 +1,4 @@ -export { default as Page } from "./page"; export { default as User } from "./user"; export * from "./consts"; -// FIXME: temp? export * from "./hooks"; +export * from "./firebase"; diff --git a/src/features/auth/types.ts b/src/features/auth/types.ts index 1602490..ce697b8 100644 --- a/src/features/auth/types.ts +++ b/src/features/auth/types.ts @@ -4,10 +4,18 @@ export type CredentialWithToken = { }; }; +/** + * Контекст авторизации + */ export type AuthContext = import("firebase").default.auth.UserCredential & CredentialWithToken; +/** + * Учетные данные пользователя + */ export type UserCredential = { + /** Токен доступа */ accessToken: string; + /** Логин пользователя */ username: string; // NOTE: Возможно список хранимых полей будет расширяться позднее }; diff --git a/src/features/auth/user/index.tsx b/src/features/auth/user/index.tsx index a93da94..5a0cead 100644 --- a/src/features/auth/user/index.tsx +++ b/src/features/auth/user/index.tsx @@ -1,8 +1,12 @@ import React from "react"; import { Button } from "antd"; +import { Link } from "react-router-dom"; import { useAuth } from "../hooks"; import { routes } from "../consts"; +/** + * Плашка пользователя с базовой информацией + */ const User = () => { const { isAuth, logout, viewer } = useAuth(); @@ -15,9 +19,9 @@ const User = () => { {isAuth && ( <> {/* FIXME: use h3 instead */} - + {viewer?.username} - + diff --git a/src/features/error/catcher.tsx b/src/features/error/catcher.tsx new file mode 100644 index 0000000..0748b1e --- /dev/null +++ b/src/features/error/catcher.tsx @@ -0,0 +1,54 @@ +import React, { useEffect, useState, ReactNode } from "react"; +import { onError } from "@apollo/client/link/error"; +import { useApolloClient } from "@apollo/client"; +import { useLocation } from "react-router"; +import { AppError } from "models"; +import { Definitions } from "./definitions"; +import { mapError } from "./helpers"; + +type Props = PropsWithChildren<{ + /** Отрисовщик-обработчик ошибки */ + handler: (props: { error: AppError }) => ReactNode; + onNetworkError?: (error: AppError) => void; +}>; + +/** + * @hook Логика обработки и хранения ошибок + */ +const useAppError = () => { + const apolloClient = useApolloClient(); + const [error, setError] = useState(null); + + useEffect(() => { + const errorLink = onError(({ graphQLErrors, networkError }) => { + const appError = mapError(graphQLErrors?.[0] || networkError); + setError(appError || null); + }); + apolloClient.setLink(errorLink.concat(apolloClient.link)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { error, setError }; +}; + +/** + * @feature Обертка для обработки ошибок + * FIXME: add ErrorBoundaries + */ +const Catcher = ({ handler, children, onNetworkError }: Props) => { + const location = useLocation(); + const { error, setError } = useAppError(); + + useEffect(() => setError(null), [location, setError]); + useEffect(() => { + if (error?.code !== Definitions.NETWORK_ERROR.code) return; + onNetworkError?.(error); + }, [error, onNetworkError]); + + if (error && error.code !== Definitions.NETWORK_ERROR.code) { + return <>{handler({ error })}; + } + return <>{children}; +}; + +export default Catcher; diff --git a/src/app/error-handling/error-definitions.ts b/src/features/error/definitions.ts similarity index 70% rename from src/app/error-handling/error-definitions.ts rename to src/features/error/definitions.ts index b11d3fc..8051907 100644 --- a/src/app/error-handling/error-definitions.ts +++ b/src/features/error/definitions.ts @@ -1,6 +1,9 @@ import { AppError } from "models"; -export const ErrorDefinitions: Record = { +/** + * Обрабатываемые ошибки приложения + */ +export const Definitions: Record = { UNAUTHORIZED: { code: 401, message: "You shall not pass!", @@ -21,4 +24,9 @@ export const ErrorDefinitions: Record = { message: "500 Server Error\nSomething’s wrong!", description: "we’re doing smth", }, + NETWORK_ERROR: { + code: -1, + message: "Check your connection", + description: "Failed to make a request", + }, }; diff --git a/src/features/error/helpers.ts b/src/features/error/helpers.ts new file mode 100644 index 0000000..0778120 --- /dev/null +++ b/src/features/error/helpers.ts @@ -0,0 +1,40 @@ +import { ServerError } from "@apollo/client"; +import { GraphQLError } from "graphql"; +import { AppError } from "models"; +import { Definitions } from "./definitions"; + +/** + * Проверка: является ли ошибка GitHub Error + */ +const isGithubError = (error: any): error is { type: string } => { + return typeof error.type === "string"; +}; + +/** + * Проверка: является ли ошибка серверной + */ +const isServerError = (error: any): error is ServerError => { + return typeof error.statusCode === "number"; +}; + +/** + * Соответствие полученной ошибки с прописанными и обрабатываемыми на уровне приложения + * @see ErrorDefinitions + */ +export function mapError(error?: GraphQLError | Error | ServerError): AppError | null { + if (!error) return null; + if (isGithubError(error)) { + // FIXME: handle 403 and 500 errors as well w/o side effects + if (error.type === "NOT_FOUND") { + return Definitions[error.type]; + } + } + if (isServerError(error)) { + if (error.statusCode === 401) return Definitions.UNAUTHORIZED; + } + if (error instanceof TypeError && error.message === "Failed to fetch") { + return Definitions.NETWORK_ERROR; + } + // TODO: handle other errors and whatever can be broken + return null; +} diff --git a/src/features/error/index.ts b/src/features/error/index.ts new file mode 100644 index 0000000..be66f68 --- /dev/null +++ b/src/features/error/index.ts @@ -0,0 +1,2 @@ +export { default as Catcher } from "./catcher"; +export * from "./definitions"; diff --git a/src/features/hero-sheet/index.tsx b/src/features/hero-sheet/index.tsx index e1236fa..bf28ee1 100644 --- a/src/features/hero-sheet/index.tsx +++ b/src/features/hero-sheet/index.tsx @@ -5,19 +5,35 @@ import { ReactComponent as Icon } from "./assets/github-icon.svg"; import { ReactComponent as SadIcon } from "./assets/github-icon-sad.svg"; import "./index.scss"; +type UserAction = { + /** Текст действия */ + text: string; + /** Обработчик действия */ + to: () => void; +}; + type Props = { + /** Основной заголовок */ title: string; + /** Описание/примечание */ description: string; - action?: { text: string; to: () => void }; + /** Предлагаемое действие */ + action?: UserAction; + /** Использовать `sad` аватар */ useSadHero?: boolean; }; +/** + * Hero секция + * @remark Используется как базовое отображение информации для HomePage, ErrorPage + */ const HeroSheet = ({ title, description, action, useSadHero = false }: Props) => { const history = useHistory(); - const preferredAction = action ?? { + const backAction: UserAction = { text: "Back", to: () => (history.length > 1 ? history.goBack() : history.push("/")), }; + const preferredAction = action ?? backAction; const HeroIcon = useSadHero ? SadIcon : Icon; return ( diff --git a/src/features/index.ts b/src/features/index.ts index bc7c243..0e6b309 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -1,5 +1,6 @@ // Импортим отдельно, т.к. фича экспортит из себя много подмодулей import * as Auth from "./auth"; +import * as Error from "./error"; // FIXME: Нормализовать экспорты к единому виду @@ -12,3 +13,4 @@ export { default as Search } from "./search"; export { default as Origin } from "./origin"; export { default as HeroSheet } from "./hero-sheet"; export { Auth }; +export { Error }; diff --git a/src/features/repo-details/card-collaborators/index.tsx b/src/features/repo-details/card-collaborators/index.tsx new file mode 100644 index 0000000..84ac0e0 --- /dev/null +++ b/src/features/repo-details/card-collaborators/index.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Link } from "react-router-dom"; +import DetailsCard from "../details-card"; +import { RepoDetailsQuery } from "../queries.gen"; + +// FIXME: move to models +type Collaborator = { + /** id */ + id: string; + /** Имя пользователя */ + name: string; + /** Логин пользователя */ + login: string; + /** Аватар */ + avatarUrl: string; +}; + +type Props = { + /** Данные по репозиторию */ + repository: RepoDetailsQuery["repository"]; +}; + +/** + * Коллабораторы репозитория + */ +const CardCollaborators = ({ repository }: Props) => { + const collaborators = repository?.collaborators?.nodes?.filter( + (collaborator): collaborator is Collaborator => !!collaborator, + ); + + if (!collaborators) return null; + return ( + + {collaborators?.map(({ id, login, avatarUrl }) => ( +
+ avatar + + {login} + +
+ ))} +
+ ); +}; + +export default CardCollaborators; diff --git a/src/features/repo-details/card-common/index.tsx b/src/features/repo-details/card-common/index.tsx new file mode 100644 index 0000000..67cebf1 --- /dev/null +++ b/src/features/repo-details/card-common/index.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { Skeleton, Tag } from "antd"; +import { Language, RepoIdentity } from "models"; +import DetailsCard from "../details-card"; +import { RepoDetailsQuery } from "../queries.gen"; + +type Props = { + /** Данные по репозиторию */ + repository: RepoDetailsQuery["repository"]; + /** repo identity */ + identity: RepoIdentity; + /** Флаг загрузки */ + loading: boolean; +}; + +/** + * Общая информация по репозиторию + */ +const CardCommon = (props: Props) => { + const { repository, identity, loading } = props; + const languages = repository?.languages?.nodes?.filter((lang): lang is Language => !!lang); + return ( + + {loading && ( + + )} + {repository?.description ? ( +
{repository?.description}
+ ) : ( +

No description, website, or topics provided.

+ )} +
+ {repository?.homepageUrl && ( + + {repository.homepageUrl} + + )} + {languages?.map(({ id, name, color }) => ( + + {name} + + ))} +
+ ); +}; + +export default CardCommon; diff --git a/src/features/repo-details/details-card/index.tsx b/src/features/repo-details/details-card/index.tsx index 1595942..7786c3d 100644 --- a/src/features/repo-details/details-card/index.tsx +++ b/src/features/repo-details/details-card/index.tsx @@ -4,13 +4,13 @@ import "./index.scss"; type Props = PropsWithChildren<{ title: string; className?: string; primary?: boolean }>; -function DetailsCard({ title, className, primary, children }: Props) { +const DetailsCard = ({ title, className, primary, children }: Props) => { return (

{title}

{children}
); -} +}; export default DetailsCard; diff --git a/src/features/repo-details/index.tsx b/src/features/repo-details/index.tsx index 7129b34..8e68602 100644 --- a/src/features/repo-details/index.tsx +++ b/src/features/repo-details/index.tsx @@ -1,25 +1,20 @@ -import { Skeleton, Tag, Alert } from "antd"; +import { Alert, Spin } from "antd"; import React from "react"; -import { Link } from "react-router-dom"; -import { Language, RepoIdentity } from "../../models"; -import DetailsCard from "./details-card"; +import { RepoIdentity } from "models"; +import CardCommon from "./card-common"; +import CardCollaborators from "./card-collaborators"; import { useRepoDetailsQuery } from "./queries.gen"; import "./index.scss"; -// !!! FIXME: decompose - type Props = { + /** repo identity */ repo: RepoIdentity; }; -type Collaborator = { - id: string; - name: string; - login: string; - avatarUrl: string; -}; - -function RepoDetails({ repo: identity }: Props) { +/** + * @feature Информация по репозиторию + */ +const RepoDetails = ({ repo: identity }: Props) => { const { data, loading } = useRepoDetailsQuery({ variables: { name: identity.name, @@ -29,65 +24,20 @@ function RepoDetails({ repo: identity }: Props) { }); const repository = data?.repository; - const languages = repository?.languages?.nodes?.filter( - (lang): lang is Language => lang != null, - ); - const collaborators = repository?.collaborators?.nodes?.filter( - (collaborator): collaborator is Collaborator => collaborator != null, - ); return (
- - {loading && ( - - )} - {repository?.description !== null ? ( -
{repository?.description}
- ) : ( -

No description, website, or topics provided.

- )} -
- {repository?.homepageUrl && ( - - {repository.homepageUrl} - - )} - {languages?.map(({ id, name, color }) => ( - - {name} - - ))} -
- {collaborators && ( - - {collaborators?.map(({ id, login, avatarUrl }) => ( -
- avatar - - {login} - -
- ))} -
- )} - + + + + +
); -} +}; export default RepoDetails; diff --git a/src/features/repo-explorer/components/branches-menu/index.tsx b/src/features/repo-explorer/components/branches-menu/index.tsx index 1016fe8..75bbaa9 100644 --- a/src/features/repo-explorer/components/branches-menu/index.tsx +++ b/src/features/repo-explorer/components/branches-menu/index.tsx @@ -1,19 +1,20 @@ import React from "react"; import { Menu } from "antd"; import { Link } from "react-router-dom"; -import { RepoIdentity } from "models"; +import { RepoIdentity, BranchIdentity } from "models"; import "./index.scss"; type Props = { repo: RepoIdentity; - branches?: Array<{ name: string; prefix: string }>; + branches?: Array; + onVisibleChange?: (flag: boolean) => void; }; -function BranchesMenu({ repo, branches }: Props) { +const BranchesMenu = ({ repo, branches, onVisibleChange }: Props) => { return ( - {}}> + {branches?.map((branch, index) => ( - + onVisibleChange?.(false)}> {branch.name} @@ -21,6 +22,6 @@ function BranchesMenu({ repo, branches }: Props) { ))} ); -} +}; export default BranchesMenu; diff --git a/src/features/repo-explorer/assets/file.svg b/src/features/repo-explorer/components/entries-view/file.svg similarity index 100% rename from src/features/repo-explorer/assets/file.svg rename to src/features/repo-explorer/components/entries-view/file.svg diff --git a/src/features/repo-explorer/assets/folder.svg b/src/features/repo-explorer/components/entries-view/folder.svg similarity index 100% rename from src/features/repo-explorer/assets/folder.svg rename to src/features/repo-explorer/components/entries-view/folder.svg diff --git a/src/features/repo-explorer/components/entries-view/git-file-view.tsx b/src/features/repo-explorer/components/entries-view/git-file-view.tsx new file mode 100644 index 0000000..fbd0514 --- /dev/null +++ b/src/features/repo-explorer/components/entries-view/git-file-view.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { List } from "antd"; +import { GITHUB_DOMAIN } from "shared/get-env"; +import { RepoIdentity, GitFile } from "models"; +import { ReactComponent as FileIcon } from "./file.svg"; +import { ReactComponent as FolderIcon } from "./folder.svg"; + +type GitFileViewProps = GitFile & { repo: RepoIdentity; branch: string }; + +const GitFileView = ({ name, type, repo, branch }: GitFileViewProps) => { + const baseUrl = `${GITHUB_DOMAIN}${repo.owner}/${repo.name}`; + const link = `${baseUrl}/${type}/${branch ? branch + "/" : ""}${name}`; + const EntryIcon = type === "tree" ? FolderIcon : FileIcon; + return ( + + + + ); +}; + +export default GitFileView; diff --git a/src/features/repo-explorer/components/entries-view/index.scss b/src/features/repo-explorer/components/entries-view/index.scss index 9c551e2..be6f2ee 100644 --- a/src/features/repo-explorer/components/entries-view/index.scss +++ b/src/features/repo-explorer/components/entries-view/index.scss @@ -21,7 +21,16 @@ border-top-left-radius: 5px; border-top-right-radius: 5px; - @include text-overflow(); + .commit-info { + @include text-overflow(); + + flex: 1 0; + } + + span.commit-date { + flex: 0 1; + margin-left: 12px; + } img, .avatar-placeholder { @@ -37,8 +46,7 @@ color: white; } - span.commit-message, - span.commit-date { + span.commit-message { margin-left: 5px; } } @@ -47,14 +55,25 @@ padding: 8px 0 8px 8px !important; border-bottom: 1px solid var(--clr-gray--100) !important; - img { + & > .wrapper { + display: flex; + align-items: center; + } + + .icon { width: 24px; height: auto; padding: 4px; } - span { + .origin-link { margin-left: 12px; + color: var(--clr-text); + + &:hover { + color: #0366d6; + text-decoration: underline; + } } } } diff --git a/src/features/repo-explorer/components/entries-view/index.tsx b/src/features/repo-explorer/components/entries-view/index.tsx index 1f5ab05..ebf734e 100644 --- a/src/features/repo-explorer/components/entries-view/index.tsx +++ b/src/features/repo-explorer/components/entries-view/index.tsx @@ -1,31 +1,26 @@ import React from "react"; -import dayjs from "dayjs"; import { List } from "antd"; import cn from "classnames"; -import { Link } from "react-router-dom"; +import { RepoIdentity, GitFile, GitCommit } from "models"; +import { useBranch } from "../../hooks"; import SkeletonArea from "../skeleton-area"; - -// FIXME: import as ReactComponent -import FileIcon from "../../assets/file.svg"; -import FolderIcon from "../../assets/folder.svg"; -import logo from "./placeholder.png"; +import GitFileView from "./git-file-view"; +import LastCommitHeader from "./last-commit-header"; import "./index.scss"; -type GitFile = { type: string; name: string }; type Props = { loading?: boolean; files: Array; className?: string; - lastCommit?: { - message: string; - login?: string; - avatarUrl?: string; - name?: string | null; - date: string; - }; + repo: RepoIdentity; + lastCommit?: GitCommit; }; -function EntriesView({ loading, files, lastCommit, className }: Props) { +/** + * Файлы репозитория + */ +const EntriesView = ({ loading, files, lastCommit, className, repo }: Props) => { + const { branch } = useBranch(repo); return (
{loading && } @@ -33,47 +28,11 @@ function EntriesView({ loading, files, lastCommit, className }: Props) { } dataSource={files} - renderItem={GitFileView} + renderItem={(item) => } /> )}
); -} - -const GitFileView = ({ name, type }: GitFile) => ( - -
- type - {name} -
-
-); - -const LastCommitHeader = ({ lastCommit }: { lastCommit: Props["lastCommit"] }) => ( -
-
- {lastCommit?.avatarUrl ? ( - <> - avatar - - {lastCommit?.login} - - - ) : ( - <> - avatar - {lastCommit?.name} - - )} -   - - {lastCommit?.message} - -
-
- on {dayjs(lastCommit?.date).format("D MMM YYYY")} -
-
-); +}; export default EntriesView; diff --git a/src/features/repo-explorer/components/entries-view/last-commit-header.tsx b/src/features/repo-explorer/components/entries-view/last-commit-header.tsx new file mode 100644 index 0000000..3feb671 --- /dev/null +++ b/src/features/repo-explorer/components/entries-view/last-commit-header.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import dayjs from "dayjs"; +import { Link } from "react-router-dom"; +import { GitCommit } from "models"; +import logo from "./placeholder.png"; + +type Props = { + lastCommit: GitCommit; +}; + +const LastCommitHeader = ({ lastCommit }: Props) => { + const { login, name, avatarUrl, message, date } = lastCommit || {}; + return ( +
+
+ avatar + {avatarUrl ? ( + + {login} + + ) : ( + {name} + )} +   + + {message} + +
+
+ on {dayjs(date).format("D MMM YYYY")} +
+
+ ); +}; + +export default LastCommitHeader; diff --git a/src/features/repo-explorer/components/hooks.ts b/src/features/repo-explorer/components/hooks.ts new file mode 100644 index 0000000..d04e570 --- /dev/null +++ b/src/features/repo-explorer/components/hooks.ts @@ -0,0 +1,52 @@ +import { BranchIdentity, RepoIdentity } from "models"; +import { RepoBranchInfoQuery, useRepoDefaultBranchQuery } from "../queries.gen"; + +type Props = { + repo: RepoIdentity; +}; + +/** + * @hook Получение текущей ветки репозитория + */ +export const useBranch = (repo: Props["repo"]) => { + const { data } = useRepoDefaultBranchQuery({ + variables: { + name: repo.name, + owner: repo.owner, + }, + }); + const branch = repo.branch || data?.repository?.defaultBranchRef?.name || "master"; + return { branch }; +}; + +/** + * @hook Получение нужных полей по сфетченным данным по репозиторию + */ +export const useRepoDetails = (repoInfo: RepoBranchInfoQuery | undefined) => { + const { repository } = repoInfo || {}; + const branches = (repository?.refs?.nodes || []).filter( + (branch): branch is BranchIdentity => !!branch, + ); + const files = Array.from(repository?.object?.entries ?? []).sort((a, b) => + b.type.localeCompare(a.type), + ); + const target = repository?.ref?.target; + const lastCommit = + (target && { + message: target.messageHeadline, + login: target.author?.user?.login, + avatarUrl: target.author?.user?.avatarUrl, + name: target.author?.name, + date: target.author?.date, + }) || + undefined; + + // Приходится фетчить файл по двум вариантам наименования, т.к. GitHub не умеет в insensitive case =( + const readme = repository?.contentLower?.text || repository?.contentUpper?.text || ""; + return { + branches, + files, + lastCommit, + readme, + }; +}; diff --git a/src/features/repo-explorer/components/index.tsx b/src/features/repo-explorer/components/index.tsx index ecb867b..0428f60 100644 --- a/src/features/repo-explorer/components/index.tsx +++ b/src/features/repo-explorer/components/index.tsx @@ -1,65 +1,20 @@ import React from "react"; import { RepoIdentity } from "models"; -import { - useRepoBranchInfoQuery, - useRepoDefaultBranchQuery, - RepoBranchInfoQuery, -} from "../queries.gen"; +import { useRepoBranchInfoQuery } from "../queries.gen"; import RepoToolbar from "./toolbar"; import EntriesView from "./entries-view"; import RepoReadme from "./readme"; +import { useBranch, useRepoDetails } from "./hooks"; type Props = { + /** repo identity */ repo: RepoIdentity; }; /** - * @hook Получение текущей ветки репозитория + * @feature FileExplorer репозитория */ -const useBranch = (repo: Props["repo"]) => { - const { data } = useRepoDefaultBranchQuery({ - variables: { - name: repo.name, - owner: repo.owner, - }, - }); - const branch = repo.branch || data?.repository?.defaultBranchRef?.name || "master"; - return { branch }; -}; - -/** - * @hook Получение нужных полей по сфетченным данным по репозиторию - */ -const useRepoDetails = (repoInfo: RepoBranchInfoQuery | undefined) => { - const { repository } = repoInfo || {}; - const branches = (repository?.refs?.nodes || []).filter( - (branch): branch is { name: string; prefix: string } => branch != null, - ); - const files = Array.from(repository?.object?.entries ?? []).sort((a, b) => - b.type.localeCompare(a.type), - ); - const target = repository?.ref?.target; - const lastCommit = - (target && { - message: target.messageHeadline, - login: target.author?.user?.login, - avatarUrl: target.author?.user?.avatarUrl, - name: target.author?.name, - date: target.author?.date, - }) || - undefined; - - // Приходится фетчить файл по двум вариантам наименования, т.к. GitHub не умеет в insensitive case =( - const readme = repository?.contentLower?.text || repository?.contentUpper?.text || ""; - return { - branches, - files, - lastCommit, - readme, - }; -}; - -function Explorer({ repo }: Props) { +const Explorer = ({ repo }: Props) => { const { branch } = useBranch(repo); const { loading, data } = useRepoBranchInfoQuery({ variables: { @@ -73,14 +28,20 @@ function Explorer({ repo }: Props) { }, }); const { branches, files, lastCommit, readme } = useRepoDetails(data); - + const repoUrl = `${repo.owner}/${repo.name}`; return (
- - + +
); -} +}; export default Explorer; diff --git a/src/features/repo-explorer/components/readme/index.scss b/src/features/repo-explorer/components/readme/index.scss index a40e956..3411828 100644 --- a/src/features/repo-explorer/components/readme/index.scss +++ b/src/features/repo-explorer/components/readme/index.scss @@ -1,11 +1,57 @@ .repo-readme { background-color: #ffffff; - border: 1px solid var(--clr-gray--100); - border-radius: 6px; - code { - padding: 1px 4px; - background: var(--clr-gray--100); + &__title { + font-size: 14px; + } + + &__markdown { + border: 1px solid var(--clr-gray--100); border-radius: 6px; + + // [github-styles] offsets + > :first-child { + margin-top: 0 !important; + } + // [github-styles] headers offsets + h1, + h2, + h3, + h4, + h5, + h6 { + margin-top: 24px; + margin-bottom: 16px; + } + // [github-styles] + code { + padding: 1px 4px; + background: var(--clr-gray--100); + border-radius: 6px; + } + // [github-styles] dividers for h1, h2 + h1, + h2 { + padding-bottom: 0.3em; + border-bottom: 1px solid var(--clr-gray--200); + } + // [github-styles] headers font-sizes + h3 { + font-size: 20px; + } + // [github-styles] headers font-sizes + h4 { + font-size: 16px; + } + // [github-styles] for markdown quotes + blockquote { + padding: 0 1em; + color: var(--clr-gray--800); + border-left: 0.25em solid var(--clr-gray--200); + } + // [github-styles] limit images max-width + img { + max-width: 100%; + } } } diff --git a/src/features/repo-explorer/components/readme/index.tsx b/src/features/repo-explorer/components/readme/index.tsx index 2f0bbdd..a3602a0 100644 --- a/src/features/repo-explorer/components/readme/index.tsx +++ b/src/features/repo-explorer/components/readme/index.tsx @@ -1,22 +1,101 @@ import React from "react"; import Markdown from "react-markdown"; +import gfm from "remark-gfm"; import SkeletonArea from "../skeleton-area"; import CodeRenderer from "./code-renderer"; import "./index.scss"; type Props = { + /** Текст README файла */ text: string; + /** Флаг загрузки */ loading: boolean; + /** + * Ссылка-идентификатор репозитория + * @remark Для обработки локальных ссылок + */ + repoUrl: string; + /** + * Текущая ветка + * @remark Для обработки локальных ссылок + */ + branch: string; }; -const RepoReadme = ({ text, loading }: Props) => { +/** + * @hook Обработка внутренних ссылок + * @remark + * - В README могут быть указаны ссылки на локальные ресурсы репозитория (images, files, anchors, ...) + * - Поэтому, для корректной навигации и отображения, было решено предобрабатывать подобные ссылки + */ +const useLocalUri = ({ repoUrl, branch }: Props) => { + /** + * Нормализация внутренних ссылок + * @example + * transformLocalUri("https://some-url/...") + * // => "https://some-url/..." + * transformLocalUri("#some-header") + * // => "https://github.com/${repo}#some-header" + * transformLocalUri("./SOMEFILE.md") + * // => "https://github.com/${repo}/blobk/${branch}/SOMEFILE.md" + * transformLocalUri("docs/ANOTHER.md") + * // => "https://github.com/${repo}/blobk/${branch}/docs/ANOTHER.md" + */ + const transformLinkUri = (uri: string) => { + if (uri.startsWith("http")) return uri; + if (uri.startsWith("#")) return `https://github.com/${repoUrl}${uri}`; + // Если sibling-link - нормализуем + const blobUrl = uri.replace("./", ""); + return `https://github.com/${repoUrl}/blob/${branch}/${blobUrl}`; + }; + + /** + * Получение исходника локального изображения + * FIXME: Работает только с markodwn-изображениями, потом переделать бы на общий случай + * @example + * transformImageUri("docs/search.png") + * // => https://raw.githubusercontent.com/${repo}/${branch}/docs/search.png + */ + const transformImageUri = (uri: string) => { + if (uri.startsWith("http")) return uri; + // Если sibling-link - нормализуем + const blobUrl = uri.replace("./", ""); + return `https://raw.githubusercontent.com/${repoUrl}/${branch}/${blobUrl}`; + }; + + return { transformLinkUri, transformImageUri }; +}; + +/** + * README репозитория + * TODO: Плохо обрабатываются сочетания markdown и html - возможно позже надо завезти отдельный htmlParser + * @see https://github.com/remarkjs/react-markdown + */ +const RepoReadme = (props: Props) => { + const { text, loading } = props; + const uriTransformers = useLocalUri(props); + return (
{loading && } {text && ( - - {text} - + <> + {/* TODO: add link */} +

README.md

+ + {text} + + )}
); diff --git a/src/features/repo-explorer/components/toolbar/clone-menu.tsx b/src/features/repo-explorer/components/toolbar/clone-menu.tsx index 7e59c1d..f60a9c2 100644 --- a/src/features/repo-explorer/components/toolbar/clone-menu.tsx +++ b/src/features/repo-explorer/components/toolbar/clone-menu.tsx @@ -4,11 +4,11 @@ import { Button, Input, Tooltip } from "antd"; type Props = { url: string }; -export default function CloneMenu({ url }: Props) { +const CloneMenu = ({ url }: Props) => { const cloneField = useRef(null); const [isUrlCopied, setUrlCopied] = useState(null); useEffect(() => { - if (isUrlCopied == null) return; + if (isUrlCopied === null) return; setTimeout(() => setUrlCopied(null), 1000); }, [isUrlCopied]); @@ -37,7 +37,7 @@ export default function CloneMenu({ url }: Props) { @@ -32,6 +44,6 @@ function RepoToolbar({ repo, branches, activeBranch }: Props) { ); -} +}; export default RepoToolbar; diff --git a/src/features/repo-explorer/hooks.ts b/src/features/repo-explorer/hooks.ts new file mode 100644 index 0000000..e8928fa --- /dev/null +++ b/src/features/repo-explorer/hooks.ts @@ -0,0 +1,48 @@ +import { BranchIdentity, RepoIdentity } from "models"; +import { RepoBranchInfoQuery, useRepoDefaultBranchQuery } from "./queries.gen"; + +/** + * @hook Получение текущей ветки репозитория + */ +export const useBranch = (repo: RepoIdentity) => { + const { data } = useRepoDefaultBranchQuery({ + variables: { + name: repo.name, + owner: repo.owner, + }, + }); + const branch = repo.branch || data?.repository?.defaultBranchRef?.name || "master"; + return { branch }; +}; + +/** + * @hook Получение нужных полей по сфетченным данным по репозиторию + */ +export const useRepoDetails = (repoInfo: RepoBranchInfoQuery | undefined) => { + const { repository } = repoInfo || {}; + const branches = (repository?.refs?.nodes || []).filter( + (branch): branch is BranchIdentity => !!branch, + ); + const files = Array.from(repository?.object?.entries ?? []).sort((a, b) => + b.type.localeCompare(a.type), + ); + const target = repository?.ref?.target; + const lastCommit = + (target && { + message: target.messageHeadline, + login: target.author?.user?.login, + avatarUrl: target.author?.user?.avatarUrl, + name: target.author?.name, + date: target.author?.date, + }) || + undefined; + + // Приходится фетчить файл по двум вариантам наименования, т.к. GitHub не умеет в insensitive case =( + const readme = repository?.contentLower?.text || repository?.contentUpper?.text || ""; + return { + branches, + files, + lastCommit, + readme, + }; +}; diff --git a/src/features/repo-list/index.scss b/src/features/repo-list/index.scss index 3342731..1e780d5 100644 --- a/src/features/repo-list/index.scss +++ b/src/features/repo-list/index.scss @@ -2,7 +2,7 @@ margin-left: 38px; &__placeholder { - margin: 20px 0; + margin: 30px 0; text-align: center; } diff --git a/src/features/repo-list/index.tsx b/src/features/repo-list/index.tsx index 3ed426d..7b6a2d0 100644 --- a/src/features/repo-list/index.tsx +++ b/src/features/repo-list/index.tsx @@ -1,18 +1,20 @@ import React from "react"; -import { Repo, Tabs, SimplePagination, Card } from "shared/components"; -import { str, dom } from "shared/helpers"; import { useReposQuery } from "./queries.gen"; -import { useFilters, PAGE_SIZE, useStarring } from "./hooks"; -import { tabsMap } from "./params"; +import { useFilters, useStarring } from "./hooks"; +import Tabs from "./tabs"; +import Items from "./items"; +import Pagination from "./pagination"; import "./index.scss"; type Props = { + /** @routeParam Логин пользователя текущей страницы */ username: string; }; /** * @feature Список репозиториев пользователя * FIXME: rename to UserRepoList? (coz - user as dep) + * FIXME: simplify inner components */ const RepoList = ({ username }: Props) => { const { handleTabClick, handlePaginationClick, config } = useFilters(); @@ -21,59 +23,23 @@ const RepoList = ({ username }: Props) => { }); // TODO: transmit id and viewerHasStarred of nodes to handler func const starring = useStarring(variables); - const { pageInfo, totalCount = 0, nodes } = data?.user?.repositories || {}; - const length = nodes?.length; + const { repositories } = data?.user || {}; return (
- - {Object.keys(tabsMap).map((type) => ( - handleTabClick(type)} - label={config.tab === type && !loading ? String(totalCount) : undefined} - /> - ))} - - -
- {/* NOTE: А то все {PAGE_SIZE} плейсхолдеров слишком много */} - {loading && } - {length !== 0 ? ( - data?.user?.repositories.nodes?.map((node) => ( - starring.handle(node?.id, node?.viewerHasStarred)} - key={node?.id} - data={node} - loading={starring.debouncedLoadingId === node?.id} - /> - )) - ) : ( -

- {username} doesn’t have any repositories yet. -

- )} -
-
- {totalCount > PAGE_SIZE && pageInfo && ( - { - handlePaginationClick({ before: pageInfo.startCursor }); - dom.scrollToTop(); - }} - onNext={() => { - handlePaginationClick({ after: pageInfo.endCursor }); - dom.scrollToTop(); - }} - hasNextPage={pageInfo.hasNextPage} - hasPrevPage={pageInfo.hasPreviousPage} - center - /> - )} -
+ + +
); }; diff --git a/src/features/repo-list/items/index.tsx b/src/features/repo-list/items/index.tsx new file mode 100644 index 0000000..9628d6e --- /dev/null +++ b/src/features/repo-list/items/index.tsx @@ -0,0 +1,49 @@ +import React from "react"; +import { Empty } from "antd"; +import { Repo, Card } from "shared/components"; +import { VeryMaybe, Repository } from "models"; +import { RepositoriesDetailsFragment } from "../queries.gen"; +import { useStarring } from "../hooks"; + +type Props = { + /** Флаг загрузки */ + loading: boolean; + /** Список элементов-репозиториев */ + nodes: RepositoriesDetailsFragment["nodes"]; + /** Мутации по star/unstarring */ + starring: ReturnType; + /** Логин owner-а */ + username: string; +}; + +/** + * Список репозиториев + */ +const RepoListItems = (props: Props) => { + const { loading, nodes, starring, username } = props; + const length = nodes?.length; + + return ( +
+ {/* NOTE: А то все {PAGE_SIZE} плейсхолдеров слишком много */} + {loading && } + {length !== 0 ? ( + nodes?.map((node) => ( + starring.handle(node?.id, node?.viewerHasStarred)} + key={node?.id} + data={node as VeryMaybe} + loading={starring.debouncedLoadingId === node?.id} + /> + )) + ) : ( + {username} doesn’t have any repositories yet.} + /> + )} +
+ ); +}; + +export default RepoListItems; diff --git a/src/features/repo-list/pagination/index.tsx b/src/features/repo-list/pagination/index.tsx new file mode 100644 index 0000000..260567c --- /dev/null +++ b/src/features/repo-list/pagination/index.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { SimplePagination } from "shared/components"; +import { dom } from "shared/helpers"; +import { RepositoriesDetailsFragment } from "../queries.gen"; +import { useFilters, PAGE_SIZE } from "../hooks"; + +type Props = Partial & { + /** Обработчик пагинации */ + handlePaginationClick: ReturnType["handlePaginationClick"]; +}; + +/** + * Пагинация списка репозиториев + */ +const RepoListPagination = (props: Props) => { + const { pageInfo, totalCount = 0, handlePaginationClick } = props; + + return ( +
+ {totalCount > PAGE_SIZE && pageInfo && ( + { + handlePaginationClick({ before: pageInfo.startCursor }); + dom.scrollToTop(); + }} + onNext={() => { + handlePaginationClick({ after: pageInfo.endCursor }); + dom.scrollToTop(); + }} + hasNextPage={pageInfo.hasNextPage} + hasPrevPage={pageInfo.hasPreviousPage} + center + /> + )} +
+ ); +}; + +export default RepoListPagination; diff --git a/src/features/repo-list/queries.gen.ts b/src/features/repo-list/queries.gen.ts index 2ed8b41..afb3e7a 100644 --- a/src/features/repo-list/queries.gen.ts +++ b/src/features/repo-list/queries.gen.ts @@ -3,6 +3,8 @@ import * as Types from '../../models.gen'; import { gql } from '@apollo/client'; import * as Apollo from '@apollo/client'; +export type RepositoriesDetailsFragment = { readonly totalCount: number, readonly pageInfo: { readonly endCursor?: Types.Maybe, readonly startCursor?: Types.Maybe, readonly hasNextPage: boolean, readonly hasPreviousPage: boolean }, readonly nodes?: Types.Maybe, readonly name: string }>, readonly owner: { readonly login: string } | { readonly login: string } }>>> }; + export type ReposQueryVariables = Types.Exact<{ login: Types.Scalars['String']; ownerAffiliations?: Types.Maybe>>; @@ -13,7 +15,7 @@ export type ReposQueryVariables = Types.Exact<{ }>; -export type ReposQuery = { readonly user?: Types.Maybe<{ readonly id: string, readonly repositories: { readonly totalCount: number, readonly pageInfo: { readonly endCursor?: Types.Maybe, readonly startCursor?: Types.Maybe, readonly hasNextPage: boolean, readonly hasPreviousPage: boolean }, readonly nodes?: Types.Maybe, readonly name: string }>, readonly owner: { readonly login: string } | { readonly login: string } }>>> } }> }; +export type ReposQuery = { readonly user?: Types.Maybe<{ readonly id: string, readonly repositories: RepositoriesDetailsFragment }> }; export type AddStarMutationVariables = Types.Exact<{ starrableId: Types.Scalars['ID']; @@ -29,36 +31,40 @@ export type RemoveStarMutationVariables = Types.Exact<{ export type RemoveStarMutation = { readonly removeStar?: Types.Maybe<{ readonly starrable?: Types.Maybe<{ readonly id: string } | { readonly id: string } | { readonly id: string }> }> }; - +export const RepositoriesDetailsFragmentDoc = gql` + fragment RepositoriesDetails on RepositoryConnection { + pageInfo { + endCursor + startCursor + hasNextPage + hasPreviousPage + } + totalCount + nodes { + id + name + primaryLanguage { + color + name + } + owner { + login + } + updatedAt + viewerHasStarred + } +} + `; export const ReposDocument = gql` query Repos($login: String!, $ownerAffiliations: [RepositoryAffiliation], $after: String, $before: String, $first: Int, $last: Int) { user(login: $login) { id repositories(ownerAffiliations: $ownerAffiliations, orderBy: {field: PUSHED_AT, direction: DESC}, after: $after, before: $before, first: $first, last: $last) { - pageInfo { - endCursor - startCursor - hasNextPage - hasPreviousPage - } - totalCount - nodes { - id - name - primaryLanguage { - color - name - } - owner { - login - } - updatedAt - viewerHasStarred - } + ...RepositoriesDetails } } } - `; + ${RepositoriesDetailsFragmentDoc}`; /** * __useReposQuery__ diff --git a/src/features/repo-list/queries.gql b/src/features/repo-list/queries.gql index 03ba11f..e13573c 100644 --- a/src/features/repo-list/queries.gql +++ b/src/features/repo-list/queries.gql @@ -1,8 +1,5 @@ -query Repos ($login: String!, $ownerAffiliations: [RepositoryAffiliation], $after: String, $before: String, $first: Int, $last: Int) { - user(login: $login) { - id - repositories(ownerAffiliations: $ownerAffiliations, orderBy: {field: PUSHED_AT, direction: DESC}, after: $after, before: $before, first: $first, last: $last) { - pageInfo { +fragment RepositoriesDetails on RepositoryConnection { + pageInfo { endCursor startCursor hasNextPage @@ -22,6 +19,13 @@ query Repos ($login: String!, $ownerAffiliations: [RepositoryAffiliation], $afte updatedAt viewerHasStarred } +} + +query Repos ($login: String!, $ownerAffiliations: [RepositoryAffiliation], $after: String, $before: String, $first: Int, $last: Int) { + user(login: $login) { + id + repositories(ownerAffiliations: $ownerAffiliations, orderBy: {field: PUSHED_AT, direction: DESC}, after: $after, before: $before, first: $first, last: $last) { + ...RepositoriesDetails } } } diff --git a/src/features/repo-list/tabs/index.tsx b/src/features/repo-list/tabs/index.tsx new file mode 100644 index 0000000..6c27854 --- /dev/null +++ b/src/features/repo-list/tabs/index.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { Tabs } from "shared/components"; +import { str } from "shared/helpers"; +import { useFilters } from "../hooks"; +import { tabsMap } from "../params"; + +type Props = { + /** Фильтры */ + config: ReturnType["config"]; + /** Обработчик смены вкладки */ + handleTabClick: ReturnType["handleTabClick"]; + /** Флаг загрузки */ + loading: boolean; + /** Общее кол-во элементов */ + totalCount?: number; +}; + +/** + * Вкладки списка репозиториев + */ +const RepoListTabs = (props: Props) => { + const { config, handleTabClick, loading, totalCount } = props; + + return ( + + {Object.keys(tabsMap).map((type) => ( + handleTabClick(type)} + label={ + config.tab === type && !loading && totalCount + ? String(totalCount) + : undefined + } + /> + ))} + + ); +}; + +export default RepoListTabs; diff --git a/src/features/repo-stat/fixtures.tsx b/src/features/repo-stat/fixtures.tsx index b73d8ba..b074761 100644 --- a/src/features/repo-stat/fixtures.tsx +++ b/src/features/repo-stat/fixtures.tsx @@ -14,7 +14,7 @@ export const prettyValue = (amount: number | undefined) => { if (!amount) return ""; if (amount < THOUSAND) return `${amount}`; if (amount < MILLION) return `${(amount / THOUSAND).toFixed(1)}K`; - return `${(amount / MILLION).toFixed(1)}K`; + return `${(amount / MILLION).toFixed(1)}M`; }; export type StatName = "watchers" | "stargazers" | "forks"; diff --git a/src/features/repo-stat/index.scss b/src/features/repo-stat/index.scss index 04433f3..db9b648 100644 --- a/src/features/repo-stat/index.scss +++ b/src/features/repo-stat/index.scss @@ -45,4 +45,14 @@ border-bottom-right-radius: var(--radius); } } + + &__skeleton.ant-skeleton-element { + flex-basis: 30%; + width: 30%; + border-radius: var(--radius); + + & > span { + width: 100%; + } + } } diff --git a/src/features/repo-stat/index.tsx b/src/features/repo-stat/index.tsx index 1f5bb05..6b8bf90 100644 --- a/src/features/repo-stat/index.tsx +++ b/src/features/repo-stat/index.tsx @@ -8,6 +8,7 @@ import "./index.scss"; // NOTE: Я просто хотел отобразить статистику без нагромождений... type Props = { + /** repo identity */ repo: RepoIdentity; }; @@ -20,14 +21,28 @@ const RepoStat = ({ repo }: Props) => { return (
- {loading && } + {loading && ( +
+ + + +
+ )} {!loading && (
{stats.map(({ icon, link, name }) => ( {icon}} + title={ + + {icon} + + } value={prettyValue(data?.repository?.[name as StatName].totalCount)} /> ))} diff --git a/src/features/repo-stat/queries.gen.ts b/src/features/repo-stat/queries.gen.ts index bf4b7e1..fc7b97c 100644 --- a/src/features/repo-stat/queries.gen.ts +++ b/src/features/repo-stat/queries.gen.ts @@ -9,12 +9,13 @@ export type RepoStatQueryVariables = Types.Exact<{ }>; -export type RepoStatQuery = { readonly repository?: Types.Maybe<{ readonly forks: { readonly totalCount: number }, readonly stargazers: { readonly totalCount: number }, readonly watchers: { readonly totalCount: number } }> }; +export type RepoStatQuery = { readonly repository?: Types.Maybe<{ readonly id: string, readonly forks: { readonly totalCount: number }, readonly stargazers: { readonly totalCount: number }, readonly watchers: { readonly totalCount: number } }> }; export const RepoStatDocument = gql` query RepoStat($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { + id forks { totalCount } diff --git a/src/features/repo-stat/queries.gql b/src/features/repo-stat/queries.gql index 0872ebd..56a9bac 100644 --- a/src/features/repo-stat/queries.gql +++ b/src/features/repo-stat/queries.gql @@ -1,5 +1,6 @@ query RepoStat($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { + id # Чего не сделаешь ради единого API... forks { totalCount diff --git a/src/features/search/filters/index.tsx b/src/features/search/filters/index.tsx index 309711f..58a082c 100644 --- a/src/features/search/filters/index.tsx +++ b/src/features/search/filters/index.tsx @@ -6,6 +6,8 @@ import "./index.scss"; /** * @feature Фильтры для поиска + * @remark Допустимые типы: `Users`, `Repositories` + * @see typesMap */ const SearchFilters = () => { const { searchType, setSearchType } = useSearchTypeParam(); diff --git a/src/features/search/index.tsx b/src/features/search/index.tsx index ace30d1..4ee0fc6 100644 --- a/src/features/search/index.tsx +++ b/src/features/search/index.tsx @@ -2,6 +2,7 @@ import React from "react"; import Results from "./results"; import Filters from "./filters"; import * as params from "./params"; + // FIXME: split later by features with common cluster? /** diff --git a/src/features/search/params.ts b/src/features/search/params.ts index e1553e1..9a3d340 100644 --- a/src/features/search/params.ts +++ b/src/features/search/params.ts @@ -8,6 +8,7 @@ import { SearchType } from "models"; //#region SearchQuery /** * @qparam Поисковой запрос + * @searchQuery */ export const useSearchQueryParam = () => { const [searchQuery, setSearchQuery] = useQueryParam("q", withDefault(StringParam, "")); @@ -23,6 +24,10 @@ export const useSearchQueryParam = () => { type SearchTypeStr = "repositories" | "users"; +/** + * Общие доступные типы сущностей для фильтрации + * @filterType + */ export const typesMap: Record = { repositories: SearchType.Repository, users: SearchType.User, @@ -30,6 +35,7 @@ export const typesMap: Record = { /** * @qparam Тип результатов поиска + * @filterType */ export const useSearchTypeParam = () => { const [searchType, setSearchType] = useQueryParam( @@ -50,13 +56,19 @@ export const useSearchTypeParam = () => { //#region Sort type SortOrder = "asc" | "desc"; type SortParams = { + /** Направление сортировки */ o: SortOrder | undefined; + /** Сортируемое поле */ s: string | undefined; }; type SortVariant = SortParams & { label: string; }; +/** + * Фабрика по генерации вариантов сортировки + * @sort + */ export const createSortVariant = (field: string, label = field): SortVariant[] => { return [ { label: `Most ${label}`, o: "desc", s: field }, @@ -64,9 +76,18 @@ export const createSortVariant = (field: string, label = field): SortVariant[] = ]; }; +/** + * Вариант сортировки по-умолчанию + * @sort + */ export const defaultSortVariant: SortVariant = { label: "Best Match", o: undefined, s: undefined }; -// FIXME: simplify generating/declaring/work with sortVariants +/** + * Общие доступные варианты сортировки + * @sort + * @see https://github.com/search + * FIXME: simplify generating/declaring/work with sortVariants + */ export const sortVariantsTotal: Record = { repositories: [ defaultSortVariant, @@ -84,6 +105,7 @@ export const sortVariantsTotal: Record = { /** * @qparam Сортировка поисковых результатов + * @sort * FIXME: Перенести часть логики в использующий компонент * FIXME: Попробовать убрать явную зависимость параметров */ diff --git a/src/features/search/results/hooks.ts b/src/features/search/results/hooks.ts new file mode 100644 index 0000000..4b26e28 --- /dev/null +++ b/src/features/search/results/hooks.ts @@ -0,0 +1,36 @@ +import { dom } from "shared/helpers"; +import { SearchType } from "models"; +import * as Params from "../params"; + +export const PAGE_SIZE = 10; + +/** + * @hook Работа с поиском, фильтрацией, сортировкой и пагинацией + */ +export const useSearch = () => { + const { sortOrder, sortField } = Params.useSearchSortParams(); + const { searchQuery } = Params.useSearchQueryParam(); + const { searchTypeEnum } = Params.useSearchTypeParam(); + const { page, setPage } = Params.usePageParam(); + + const handlePageChange = (page: number) => { + setPage(page); + dom.scrollToTop(); + }; + + const isUserSearch = searchTypeEnum === SearchType.User; + const isRepoSearch = searchTypeEnum === SearchType.Repository; + + return { + type: searchTypeEnum, + query: `${searchQuery} sort:${sortField}-${sortOrder}`, + queryClean: searchQuery, + // Супер пагинация от Нияза (niyazm524) + after: btoa(`cursor:${(page - 1) * PAGE_SIZE}`), + page, + first: PAGE_SIZE, + handlePageChange, + isUserSearch, + isRepoSearch, + }; +}; diff --git a/src/features/search/results/index.tsx b/src/features/search/results/index.tsx index d53a78a..d7473ac 100644 --- a/src/features/search/results/index.tsx +++ b/src/features/search/results/index.tsx @@ -1,51 +1,13 @@ import React from "react"; -import cn from "classnames"; -import { Skeleton, Empty, Pagination } from "antd"; -import { Repo, User, Org, Card } from "shared/components"; -import { dom } from "shared/helpers"; -import { SearchType } from "models"; -import * as Params from "../params"; +import Toolbar from "./toolbar"; +import List from "./list"; +import Pagination from "./pagination"; +import { useSearch, PAGE_SIZE } from "./hooks"; import { useSearchQuery } from "./queries.gen"; -import SortSelect from "./sort-select"; -import "./index.scss"; - -// FIXME: decompose - -const PAGE_SIZE = 10; - -/** - * @hook Работа с поиском, фильтрацией, сортировкой и пагинацией - */ -const useSearch = () => { - const { sortOrder, sortField } = Params.useSearchSortParams(); - const { searchQuery } = Params.useSearchQueryParam(); - const { searchTypeEnum } = Params.useSearchTypeParam(); - const { page, setPage } = Params.usePageParam(); - - const handlePageChange = (page: number) => { - setPage(page); - dom.scrollToTop(); - }; - - const isUserSearch = searchTypeEnum === SearchType.User; - const isRepoSearch = searchTypeEnum === SearchType.Repository; - - return { - type: searchTypeEnum, - query: `${searchQuery} sort:${sortField}-${sortOrder}`, - queryClean: searchQuery, - // Супер пагинация от Нияза (niyazm524) - after: btoa(`cursor:${(page - 1) * PAGE_SIZE}`), - page, - first: PAGE_SIZE, - handlePageChange, - isUserSearch, - isRepoSearch, - }; -}; /** * @feature Результаты поиска + * @remark Отображение результатов поиска на основании запроса и конфига */ const SearchResults = () => { const { handlePageChange, page, isUserSearch, isRepoSearch, ...searchConfig } = useSearch(); @@ -62,65 +24,23 @@ const SearchResults = () => { return (
-

- - {loading && ( - - )} - {!loading && ( - <> - {count} results by {searchConfig.queryClean}: - - )} - - -

-
- {loading && } - {data?.search.nodes?.map((node) => ( - - {isRepoSearch && } - {/* !!! FIXME: simplify */} - {isUserSearch && - ((node as any)?.__typename === "Organization" ? ( - - ) : ( - - ))} - - ))} - {isEmpty && } -
-
- {count > PAGE_SIZE && ( - - )} -
+ + +
); }; -const ResultItem = ({ children, className }: PropsWithChildren & PropsWithClassName) => ( -
{children}
-); - export default SearchResults; diff --git a/src/features/search/results/list/index.scss b/src/features/search/results/list/index.scss new file mode 100644 index 0000000..c14fe3e --- /dev/null +++ b/src/features/search/results/list/index.scss @@ -0,0 +1,6 @@ +.search-results { + &__item { + user-select: none; + border-radius: 10px; + } +} diff --git a/src/features/search/results/list/index.tsx b/src/features/search/results/list/index.tsx new file mode 100644 index 0000000..4c75e99 --- /dev/null +++ b/src/features/search/results/list/index.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import cn from "classnames"; +import { Empty } from "antd"; +import { Repo, User, Org, Card } from "shared/components"; +import { VeryMaybe, Repository } from "models"; +import { SearchQuery } from "../queries.gen"; +import "./index.scss"; + +type Props = { + /** Размер страницы */ + pageSize: number; + /** Флаг загрузки */ + loading: boolean; + /** Результаты поиска */ + nodes: SearchQuery["search"]["nodes"]; + /** Флаг пустого результата поиска */ + isEmpty: boolean; + /** Флаг - поиск по репозиториям */ + isRepoSearch: boolean; + /** Флаг - поиск по пользователям */ + isUserSearch: boolean; +}; + +/** + * Список результатов поиска + */ +const ResultsList = (props: Props) => { + const { loading, nodes, pageSize, isEmpty, isRepoSearch, isUserSearch } = props; + + return ( +
+ {loading && } + {nodes?.map((node) => ( +
+ {/* !!! FIXME: simplify */} + {isRepoSearch && ( + } format="owner-repo" /> + )} + {isUserSearch && + ((node as any)?.__typename === "Organization" ? ( + + ) : ( + + ))} +
+ ))} + {isEmpty && } +
+ ); +}; + +export default ResultsList; diff --git a/src/features/search/results/pagination/index.tsx b/src/features/search/results/pagination/index.tsx new file mode 100644 index 0000000..9299b84 --- /dev/null +++ b/src/features/search/results/pagination/index.tsx @@ -0,0 +1,44 @@ +import React from "react"; +import { Pagination } from "antd"; + +type Props = { + /** Текущая страница */ + page: number; + /** Размер страницы */ + pageSize: number; + /** Количество результатов */ + count: number; + /** @handler Изменение номера страницы */ + handlePageChange: (page: number) => void; +}; + +/** + * Пагинация по поиску + */ +const ResultsPagination = (props: Props) => { + const { count, pageSize, page, handlePageChange } = props; + return ( +
+ {count > pageSize && ( + + )} +
+ ); +}; + +export default ResultsPagination; diff --git a/src/features/search/results/index.scss b/src/features/search/results/toolbar/index.scss similarity index 69% rename from src/features/search/results/index.scss rename to src/features/search/results/toolbar/index.scss index 10dfd7c..8826ac1 100644 --- a/src/features/search/results/index.scss +++ b/src/features/search/results/toolbar/index.scss @@ -9,9 +9,4 @@ max-width: 50%; } } - - &__item { - user-select: none; - border-radius: 10px; - } } diff --git a/src/features/search/results/toolbar/index.tsx b/src/features/search/results/toolbar/index.tsx new file mode 100644 index 0000000..37f2e63 --- /dev/null +++ b/src/features/search/results/toolbar/index.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Skeleton } from "antd"; +import SortSelect from "./sort-select"; +import "./index.scss"; + +type Props = { + loading: boolean; + queryClean: string; + count: number; +}; + +/** + * Тулбар результатов поиска + */ +const ResultsToolbar = ({ loading, queryClean, count }: Props) => { + return ( +

+ + {loading && ( + + )} + {!loading && ( + <> + {count} results by {queryClean}: + + )} + + +

+ ); +}; + +export default ResultsToolbar; diff --git a/src/features/search/results/sort-select.tsx b/src/features/search/results/toolbar/sort-select.tsx similarity index 93% rename from src/features/search/results/sort-select.tsx rename to src/features/search/results/toolbar/sort-select.tsx index 3c483c6..8483887 100644 --- a/src/features/search/results/sort-select.tsx +++ b/src/features/search/results/toolbar/sort-select.tsx @@ -1,10 +1,11 @@ import React from "react"; import cn from "classnames"; import { Select } from "antd"; -import * as Params from "../params"; +import * as Params from "../../params"; /** * Select-меню для выбора сортировки поисковых результатов + * @see availableVariants */ const SortSelect = ({ className }: PropsWithClassName) => { const { setSort, availableVariants, currentVariant } = Params.useSearchSortParams(); diff --git a/src/features/user-info/index.scss b/src/features/user-info/index.scss index 8203803..1cbc4b7 100644 --- a/src/features/user-info/index.scss +++ b/src/features/user-info/index.scss @@ -33,14 +33,9 @@ line-height: 28px; } - &__btn.follow, - &__btn.unfollow, - &__btn.edit { + &__btn { + display: block !important; margin-top: 10px; transition: 0.4s; - - &:hover { - cursor: pointer; - } } } diff --git a/src/features/user-info/index.tsx b/src/features/user-info/index.tsx index 6a94423..66bd83f 100644 --- a/src/features/user-info/index.tsx +++ b/src/features/user-info/index.tsx @@ -5,6 +5,7 @@ import { useFollowing } from "./hooks"; import "./index.scss"; type Props = { + /** @routeParam Логин пользователя текущей страницы */ username: string; }; @@ -32,15 +33,14 @@ const UserInfo = ({ username }: Props) => {

{name}

{username}

{bio} -

{isViewer ? ( - ) : ( - diff --git a/src/shared/components/tabs/index.tsx b/src/shared/components/tabs/index.tsx index 3d92c95..d61d870 100644 --- a/src/shared/components/tabs/index.tsx +++ b/src/shared/components/tabs/index.tsx @@ -5,9 +5,14 @@ import Item from "./item"; type Props = PropsWithChildren<{ className?: string; }>; + +/** + * @UIKit Группа вкладок + */ const Tabs = ({ children, className }: Props) => { return
{children}
; }; Tabs.Item = Item; + export default Tabs; diff --git a/src/shared/components/tabs/item/index.tsx b/src/shared/components/tabs/item/index.tsx index 96c8ca1..5be77a7 100644 --- a/src/shared/components/tabs/item/index.tsx +++ b/src/shared/components/tabs/item/index.tsx @@ -3,13 +3,24 @@ import cn from "classnames"; import "./index.scss"; type Props = { + /** Название */ name: string; + /** className */ className?: string; + /** @flag Активный */ active?: boolean; + /** @handler По клику */ onClick?: Callback; + /** + * Доп. лейбл + * @remark Обычно используется для отображения счетчика + */ label?: string; }; +/** + * @UIKit Вкладка + */ const Tab = (props: Props) => { const { name, className, active, onClick, label } = props; return ( diff --git a/src/shared/components/user/index.tsx b/src/shared/components/user/index.tsx index 3ee3325..d1456ec 100644 --- a/src/shared/components/user/index.tsx +++ b/src/shared/components/user/index.tsx @@ -1,16 +1,21 @@ import React from "react"; import { Button } from "antd"; +import { VeryMaybe, User } from "models"; import Card from "../card"; -// !!! FIXME: specify types type Props = { - data: any; + /** Данные по пользователю */ + data: VeryMaybe; + /** @handler follow/unfollow */ onFollowing?: Callback; }; -const User = (props: Props) => { +/** + * @ItemEntity Карточка пользователя + */ +const UserCard = (props: Props) => { const { data, onFollowing } = props; - const { avatarUrl, login, viewerIsFollowing, bio } = data as Partial; + const { avatarUrl, login, viewerIsFollowing, bio } = data || {}; return ( { ); }; -export default User; +export default UserCard; diff --git a/src/shared/get-env/index.ts b/src/shared/get-env/index.ts index f0b13c0..0dfb509 100644 --- a/src/shared/get-env/index.ts +++ b/src/shared/get-env/index.ts @@ -16,8 +16,14 @@ const getEnvVar = (key: string) => { return process.env[key] || ""; }; -/** API entrypoint */ +/** @github API entrypoint */ export const API_URL = getEnvVar("REACT_APP_API_URL"); +/** @github Domain link */ +export const GITHUB_DOMAIN = getEnvVar("REACT_APP_GITHUB_DOMAIN"); +/** @github Main repo link */ +export const GITHUB_MAIN = getEnvVar("REACT_APP_GITHUB_MAIN"); +/** @github Feedback link */ +export const GITHUB_FEEDBACK = getEnvVar("REACT_APP_GITHUB_FEEDBACK"); /** Режим запуска программы */ export const NODE_ENV = getEnvVar("NODE_ENV"); diff --git a/src/shared/helpers/alert.ts b/src/shared/helpers/alert.ts index 84cd293..164938b 100644 --- a/src/shared/helpers/alert.ts +++ b/src/shared/helpers/alert.ts @@ -5,9 +5,14 @@ import { notification } from "antd"; * - упрощения API (более нормализированное и привычное) * - для стандартизации единого placement для всех алертов */ -const placement = "bottomRight"; +const generateOpener = (type: import("antd/lib/notification").IconType) => ( + message: string, + description?: string, +) => { + notification.open({ type, message, description, placement: "bottomRight" }); +}; -export const error = (message: string) => notification.error({ message, placement }); -export const success = (message: string) => notification.success({ message, placement }); -export const warn = (message: string) => notification.warn({ message, placement }); -export const info = (message: string) => notification.info({ message, placement }); +export const error = generateOpener("error"); +export const success = generateOpener("success"); +export const warn = generateOpener("warning"); +export const info = generateOpener("info"); diff --git a/src/shared/helpers/compose.ts b/src/shared/helpers/compose.ts new file mode 100644 index 0000000..0b85953 --- /dev/null +++ b/src/shared/helpers/compose.ts @@ -0,0 +1,55 @@ +/** @see https://github.com/reduxjs/redux/blob/master/src/compose.ts */ + +type Func = (...a: T) => R; + +/** + * Composes single-argument functions from right to left. The rightmost + * function can take multiple arguments as it provides the signature for the + * resulting composite function. + * + * @param funcs The functions to compose. + * @returns A function obtained by composing the argument functions from right + * to left. For example, `compose(f, g, h)` is identical to doing + * `(...args) => f(g(h(...args)))`. + */ +function compose(): (a: R) => R; + +function compose(f: F): F; + +/* two functions */ +function compose(f1: (a: A) => R, f2: Func): Func; + +/* three functions */ +function compose( + f1: (b: B) => R, + f2: (a: A) => B, + f3: Func, +): Func; + +/* four functions */ +function compose( + f1: (c: C) => R, + f2: (b: B) => C, + f3: (a: A) => B, + f4: Func, +): Func; + +/* rest */ +function compose(f1: (a: any) => R, ...funcs: Function[]): (...args: any[]) => R; + +function compose(...funcs: Function[]): (...args: any[]) => R; + +function compose(...funcs: Function[]) { + if (funcs.length === 0) { + // infer the argument type so it is usable in inference down the line + return (arg: T) => arg; + } + + if (funcs.length === 1) { + return funcs[0]; + } + + return funcs.reduce((a, b) => (...args: any) => a(b(...args))); +} + +export default compose; diff --git a/src/shared/helpers/index.ts b/src/shared/helpers/index.ts index 058b581..2368b25 100644 --- a/src/shared/helpers/index.ts +++ b/src/shared/helpers/index.ts @@ -1,5 +1,6 @@ import * as str from "./string"; import * as dom from "./dom"; import * as alert from "./alert"; +import compose from "./compose"; -export { str, dom, alert }; +export { str, dom, alert, compose }; diff --git a/src/shared/helpers/string.ts b/src/shared/helpers/string.ts index 15c209e..cbd9737 100644 --- a/src/shared/helpers/string.ts +++ b/src/shared/helpers/string.ts @@ -1,3 +1,6 @@ +/** + * Сделать первую букву строки заглавной + */ export const capitalize = (term: string | null | undefined) => { if (!term) return ""; return term.charAt(0).toUpperCase() + term.slice(1);