diff --git a/README.md b/README.md deleted file mode 100644 index c6786e7..0000000 --- a/README.md +++ /dev/null @@ -1,57 +0,0 @@ -![Иллюстрация к проекту](https://user-images.githubusercontent.com/45331104/85237024-d4806880-b42b-11ea-93e2-96a39db3423a.png) - -### `yarn start` -- Runs the app in the development mode.
-- [http://localhost:3000](http://localhost:3000) to view it in the browser. - -Runs the app in the development mode.
The project supports both English and Russian localizations. -Open [http://localhost:3000](http://localhost:3000) to view it in the browser. - -## Project description - -#### Find Github repos easily and see detailed repo info. - -The project uses modern client-side web-technologies as following libraries and frameworks: `React` (with hooks) for dynamic declarative DOM manipulation and event handling, `Redux (Toolkit)` for state manegment, `Typescript` for static type checking, `Jest` for testing, `Eslint` and `Husky` for better developer experience. - -The project supports both English and Russian localizations. - -Explore Github API: https://developer.github.com/v3/ - -## Access token - -In order to increase Github API [rate limit](https://developer.github.com/v3/#rate-limiting) you should use the personal access token. - -[Create personal Github API access token](https://github.com/settings/tokens) and paste it in the `GITHUB_OAUTH_TOKEN` field of `secret_example.json` file and then rename file to `secret.json`. - -## Other scripts - -This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). - -### `yarn start:dev` -- Runs the app ignoring Typescript errors. - -### `yarn build` -- Builds the app for production. - -### `yarn test` -- Launches the test runner in the interactive watch mode. - -### `yarn eject` - -- **Note: this is a one-way operation. Once you `eject`, you can’t go back!** -It will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) into your for you to have full control over them. - -### `yarn lint` - -- Runs eslint and finds inconsistencies with Airbnb style guide and other errors. - Note the pre-commit hook does not allow to make the commit if there are some errors. - -### `yarn lint:fix` -- Runs eslint and fixes auto-fixable errors. - -### `yarn predeploy` -- Runs before deploy and creates production build. - -### `yarn deploy` -- Deploys project to Github Pages. - Note the pre-push hook makes deployment on every push. diff --git a/asset-manifest.json b/asset-manifest.json new file mode 100644 index 0000000..33411f2 --- /dev/null +++ b/asset-manifest.json @@ -0,0 +1,37 @@ +{ + "files": { + "main.css": "/github-dashboard/static/css/main.c553a624.chunk.css", + "main.js": "/github-dashboard/static/js/main.b1bcf460.chunk.js", + "main.js.map": "/github-dashboard/static/js/main.b1bcf460.chunk.js.map", + "runtime-main.js": "/github-dashboard/static/js/runtime-main.586c1031.js", + "runtime-main.js.map": "/github-dashboard/static/js/runtime-main.586c1031.js.map", + "static/js/2.bb6c04c7.chunk.js": "/github-dashboard/static/js/2.bb6c04c7.chunk.js", + "static/js/2.bb6c04c7.chunk.js.map": "/github-dashboard/static/js/2.bb6c04c7.chunk.js.map", + "static/css/3.d26afa13.chunk.css": "/github-dashboard/static/css/3.d26afa13.chunk.css", + "static/js/3.1b5d7893.chunk.js": "/github-dashboard/static/js/3.1b5d7893.chunk.js", + "static/js/3.1b5d7893.chunk.js.map": "/github-dashboard/static/js/3.1b5d7893.chunk.js.map", + "static/css/4.cc0363dc.chunk.css": "/github-dashboard/static/css/4.cc0363dc.chunk.css", + "static/js/4.801aded9.chunk.js": "/github-dashboard/static/js/4.801aded9.chunk.js", + "static/js/4.801aded9.chunk.js.map": "/github-dashboard/static/js/4.801aded9.chunk.js.map", + "static/css/5.86d72b1d.chunk.css": "/github-dashboard/static/css/5.86d72b1d.chunk.css", + "static/js/5.bf24bb47.chunk.js": "/github-dashboard/static/js/5.bf24bb47.chunk.js", + "static/js/5.bf24bb47.chunk.js.map": "/github-dashboard/static/js/5.bf24bb47.chunk.js.map", + "static/js/6.4d54a6d5.chunk.js": "/github-dashboard/static/js/6.4d54a6d5.chunk.js", + "static/js/6.4d54a6d5.chunk.js.map": "/github-dashboard/static/js/6.4d54a6d5.chunk.js.map", + "index.html": "/github-dashboard/index.html", + "precache-manifest.1c50a05aff891651a6c4d25bda5b9741.js": "/github-dashboard/precache-manifest.1c50a05aff891651a6c4d25bda5b9741.js", + "service-worker.js": "/github-dashboard/service-worker.js", + "static/css/3.d26afa13.chunk.css.map": "/github-dashboard/static/css/3.d26afa13.chunk.css.map", + "static/css/4.cc0363dc.chunk.css.map": "/github-dashboard/static/css/4.cc0363dc.chunk.css.map", + "static/css/5.86d72b1d.chunk.css.map": "/github-dashboard/static/css/5.86d72b1d.chunk.css.map", + "static/css/main.c553a624.chunk.css.map": "/github-dashboard/static/css/main.c553a624.chunk.css.map", + "static/js/2.bb6c04c7.chunk.js.LICENSE.txt": "/github-dashboard/static/js/2.bb6c04c7.chunk.js.LICENSE.txt", + "static/js/3.1b5d7893.chunk.js.LICENSE.txt": "/github-dashboard/static/js/3.1b5d7893.chunk.js.LICENSE.txt" + }, + "entrypoints": [ + "static/js/runtime-main.586c1031.js", + "static/js/2.bb6c04c7.chunk.js", + "static/css/main.c553a624.chunk.css", + "static/js/main.b1bcf460.chunk.js" + ] +} \ No newline at end of file diff --git a/chevron.svg b/chevron.svg new file mode 100644 index 0000000..bffca7f --- /dev/null +++ b/chevron.svg @@ -0,0 +1,4 @@ + + + + diff --git a/github-logo.svg b/github-logo.svg new file mode 100644 index 0000000..deb09dc --- /dev/null +++ b/github-logo.svg @@ -0,0 +1,15 @@ + + + GitHub Dashboard + + + + + + + + + + + + diff --git a/index.html b/index.html new file mode 100644 index 0000000..a442533 --- /dev/null +++ b/index.html @@ -0,0 +1 @@ +Github Dashboard
\ No newline at end of file diff --git a/logo192.png b/logo192.png new file mode 100644 index 0000000..fc44b0a Binary files /dev/null and b/logo192.png differ diff --git a/logo512.png b/logo512.png new file mode 100644 index 0000000..a4e47a6 Binary files /dev/null and b/logo512.png differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..2050efa --- /dev/null +++ b/manifest.json @@ -0,0 +1,25 @@ +{ + "short_name": "React App", + "name": "Create React App Sample", + "icons": [ + { + "src": "octocat.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + }, + { + "src": "logo192.png", + "type": "image/png", + "sizes": "192x192" + }, + { + "src": "logo512.png", + "type": "image/png", + "sizes": "512x512" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 0000000..0add366 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,8 @@ +[build] + command = "yarn build" + publish = "build" + +[[redirects]] + from = "/*" + to = "/index.html" + status = 200 diff --git a/package.json b/package.json index a2791c2..c27d470 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,10 @@ "typescript": "~3.7.2" }, "scripts": { - "start": "react-scripts start", - "start:dev": "TSC_COMPILE_ON_ERROR=true react-scripts start", - "build": "react-scripts build", - "test": "react-scripts test", + "start": "cross-env NODE_OPTIONS=--openssl-legacy-provider react-scripts start", + "start:dev": "cross-env NODE_OPTIONS=--openssl-legacy-provider TSC_COMPILE_ON_ERROR=true react-scripts start", + "build": "cross-env NODE_OPTIONS=--openssl-legacy-provider react-scripts build", + "test": "cross-env NODE_OPTIONS=--openssl-legacy-provider react-scripts test", "eject": "react-scripts eject", "lint": "eslint 'src/**'", "lint:fix": "eslint 'src/**' --fix", @@ -70,12 +70,14 @@ "eslint-plugin-react": "^7.20.0", "eslint-plugin-react-hooks": "^2.5.1", "gh-pages": "^3.0.0", - "husky": "^4.2.5" + "husky": "^4.2.5", + "cross-env": "^7.0.3" }, "husky": { "hooks": { "pre-commit": "yarn lint", - "pre-push": "CI=true react-scripts test && yarn deploy" + "pre-push": "cross-env NODE_OPTIONS=--openssl-legacy-provider CI=true react-scripts test && yarn deploy" } - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/precache-manifest.1c50a05aff891651a6c4d25bda5b9741.js b/precache-manifest.1c50a05aff891651a6c4d25bda5b9741.js new file mode 100644 index 0000000..31298e1 --- /dev/null +++ b/precache-manifest.1c50a05aff891651a6c4d25bda5b9741.js @@ -0,0 +1,58 @@ +self.__precacheManifest = (self.__precacheManifest || []).concat([ + { + "revision": "d717814133c2b1156ab0755ca3bd7190", + "url": "/github-dashboard/index.html" + }, + { + "revision": "e178ae25a60262d2c6c5", + "url": "/github-dashboard/static/css/3.d26afa13.chunk.css" + }, + { + "revision": "cef6718c953fc008212d", + "url": "/github-dashboard/static/css/4.cc0363dc.chunk.css" + }, + { + "revision": "0ee30a9f8186bc6f9d9f", + "url": "/github-dashboard/static/css/5.86d72b1d.chunk.css" + }, + { + "revision": "940c7003a01fcd3bb723", + "url": "/github-dashboard/static/css/main.c553a624.chunk.css" + }, + { + "revision": "759542c676dae195474f", + "url": "/github-dashboard/static/js/2.bb6c04c7.chunk.js" + }, + { + "revision": "c64c486544348f10a6d6c716950bc223", + "url": "/github-dashboard/static/js/2.bb6c04c7.chunk.js.LICENSE.txt" + }, + { + "revision": "e178ae25a60262d2c6c5", + "url": "/github-dashboard/static/js/3.1b5d7893.chunk.js" + }, + { + "revision": "81896c98bac7b5b16ab1d3790da5b937", + "url": "/github-dashboard/static/js/3.1b5d7893.chunk.js.LICENSE.txt" + }, + { + "revision": "cef6718c953fc008212d", + "url": "/github-dashboard/static/js/4.801aded9.chunk.js" + }, + { + "revision": "0ee30a9f8186bc6f9d9f", + "url": "/github-dashboard/static/js/5.bf24bb47.chunk.js" + }, + { + "revision": "f1e00f0434e9819825ad", + "url": "/github-dashboard/static/js/6.4d54a6d5.chunk.js" + }, + { + "revision": "940c7003a01fcd3bb723", + "url": "/github-dashboard/static/js/main.b1bcf460.chunk.js" + }, + { + "revision": "73091bd3888521af5521", + "url": "/github-dashboard/static/js/runtime-main.586c1031.js" + } +]); \ No newline at end of file diff --git a/public/github-logo.svg b/public/github-logo.svg index 0eef000..deb09dc 100644 --- a/public/github-logo.svg +++ b/public/github-logo.svg @@ -1,3 +1,15 @@ - - + + + GitHub Dashboard + + + + + + + + + + + diff --git a/robots.txt b/robots.txt new file mode 100644 index 0000000..e9e57dc --- /dev/null +++ b/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/service-worker.js b/service-worker.js new file mode 100644 index 0000000..09a75bb --- /dev/null +++ b/service-worker.js @@ -0,0 +1,39 @@ +/** + * Welcome to your Workbox-powered service worker! + * + * You'll need to register this file in your web app and you should + * disable HTTP caching for this file too. + * See https://goo.gl/nhQhGp + * + * The rest of the code is auto-generated. Please don't update this file + * directly; instead, make changes to your Workbox build configuration + * and re-run your build process. + * See https://goo.gl/2aRDsh + */ + +importScripts("https://storage.googleapis.com/workbox-cdn/releases/4.3.1/workbox-sw.js"); + +importScripts( + "/github-dashboard/precache-manifest.1c50a05aff891651a6c4d25bda5b9741.js" +); + +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); + +workbox.core.clientsClaim(); + +/** + * The workboxSW.precacheAndRoute() method efficiently caches and responds to + * requests for URLs in the manifest. + * See https://goo.gl/S9QRab + */ +self.__precacheManifest = [].concat(self.__precacheManifest || []); +workbox.precaching.precacheAndRoute(self.__precacheManifest, {}); + +workbox.routing.registerNavigationRoute(workbox.precaching.getCacheKeyForURL("/github-dashboard/index.html"), { + + blacklist: [/^\/_/,/\/[^/?]+\.[^/]+$/], +}); diff --git a/src/api/githubAPI.ts b/src/api/githubAPI.ts index e7983c0..53de4b0 100644 --- a/src/api/githubAPI.ts +++ b/src/api/githubAPI.ts @@ -25,36 +25,55 @@ interface IConfig { }, } +const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)); + +async function requestWithRetry(fn: () => Promise, retries = 3, backoff = 500): Promise { + return fn().catch(async (err) => { + const status = err && err.response && err.response.status; + if (status === 403) { + // GitHub rate-limited or forbids access — set global flag and return sentinel + try { + // eslint-disable-next-line no-undef + if (typeof window !== 'undefined') window.__GITHUB_RATE_LIMITED__ = true; + } catch (e) { + // ignore + } + return 'RATE_LIMITED'; + } + + if ((status === 429 || status === 502 || status === 503 || status === 504) && retries > 0) { + // transient error, retry with exponential backoff + await sleep(backoff); + return requestWithRetry(fn, retries - 1, backoff * 2); + } + + // for other errors return the message to preserve existing API + return (err && err.message) || 'Request failed'; + }); +} + export const fetchRepos = async (q: string, page: number): Promise => { - try { - const config: IConfig = { - params: { - q, - page, - sort: 'stars', - order: 'desc', - per_page: REPOS_PER_PAGE, - }, - }; - - const response = await axios.get(SEARCH_URL_BASE, config); - - return response.data; - } catch (e) { - log.error(e); - return e.message; - } + const config: IConfig = { + params: { + q, + page, + sort: 'stars', + order: 'desc', + per_page: REPOS_PER_PAGE, + }, + }; + + const result = await requestWithRetry(() => axios.get(SEARCH_URL_BASE, config)); + if (typeof result === 'string') return result; + return (result as any).data as GetReposResponse; }; export const fetchRepoDetails = async (id: string): Promise => { - try { - const response = await axios.get(`${REPO_URL_BASE}/${id}`); - - return response.data; - } catch (e) { - log.error(e); - return e.message; - } + // id can be numeric repo id or owner/name + const url = id && id.includes('/') ? `https://api.github.com/repos/${id}` : `${REPO_URL_BASE}/${id}`; + const result = await requestWithRetry(() => axios.get(url)); + if (typeof result === 'string') return result; + return (result as any).data as Repo; }; /** @@ -64,52 +83,55 @@ export const fetchRepoDetails = async (id: string): Promise => { * sorts them by the number of commits per contributor in descending order. */ export const fetchContributors = async (url: string): Promise => { - try { - const config = { - params: { - per_page: CONTRIBUTORS_PER_PAGE, - }, - }; - - const response = await axios.get(url, config); + const config = { + params: { + per_page: CONTRIBUTORS_PER_PAGE, + }, + }; - return response.data; - } catch (e) { - log.error(e); - return e.message; - } + const result = await requestWithRetry(() => axios.get(url, config)); + if (typeof result === 'string') return result; + return (result as any).data as Contributor[]; }; /** * https://developer.github.com/v3/repos/#list-repository-languages */ export const fetchLanguages = async (url: string): Promise => { - try { - const response = await axios.get<{[key:string]: number}>(url); + const result = await requestWithRetry(() => axios.get<{[key:string]: number}>(url)); + if (typeof result === 'string') return result; + return Object.keys((result as any).data); +}; - return Object.keys(response.data); - } catch (e) { - log.error(e); - return e.message; - } +let warnedNoToken = false; + +export const fetchReadme = async (owner: string, repo: string): Promise => { + const url = `https://api.github.com/repos/${owner}/${repo}/readme`; + // Request raw content + const result = await requestWithRetry(() => axios.get(url, { headers: { Accept: 'application/vnd.github.v3.raw' } })); + // On error requestWithRetry returns a string sentinel; return empty string to signal fallback + if (typeof result === 'string') return ''; + return (result as any).data as string; }; axios.interceptors.request.use((config: Partial = {}) => { try { - // eslint-disable-next-line global-require - const { GITHUB_OAUTH_TOKEN } = require('secret.json'); - - if (!GITHUB_OAUTH_TOKEN) { - throw new Error('No github OAuth Access Token provided or config field key "GITHUB_OAUTH_TOKEN" is misspelled.'); + // Prefer token provided via environment variable REACT_APP_GITHUB_OAUTH_TOKEN + // (create-react-app exposes REACT_APP_* vars to the browser at build time) + const token = process?.env?.REACT_APP_GITHUB_OAUTH_TOKEN || (typeof window !== 'undefined' && (window as any).__GITHUB_OAUTH_TOKEN__); + + if (token) { + // eslint-disable-next-line no-param-reassign + config.headers = { ...config.headers, Authorization: `token ${token}` }; + } else if (!warnedNoToken) { + // Log only once: missing token will cause unauthenticated requests with low rate limits + warnedNoToken = true; + log.error('No GitHub OAuth token provided (set REACT_APP_GITHUB_OAUTH_TOKEN). Requests will be unauthenticated and rate-limited.'); + log.error(`Read the README Access token section for more details: ${PROJECT_REPO_LINK}#access-token`); } - - // eslint-disable-next-line no-param-reassign - config.headers = { ...config.headers, Authorization: `token ${GITHUB_OAUTH_TOKEN}` }; } catch (e) { log.error(e); - log.error(`Read the README Access token section for more details: ${PROJECT_REPO_LINK}#access-token`); - } finally { - // eslint-disable-next-line no-unsafe-finally - return config; } + + return config; }); diff --git a/src/app/App.tsx b/src/app/App.tsx index cab13e0..439d5bb 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -11,7 +11,6 @@ const MainPage = lazy(() => import('components/MainPage')); const NotFoundPage = lazy(() => import('components/NotFoundPage')); const RepoDetails = lazy(() => import('features/repoDetails/RepoDetails')); -// TODO: cache api calls https://developer.github.com/v3/#conditional-requests const App = () => { const ref = useRef(null); diff --git a/src/app/main.css b/src/app/main.css index e530020..db5bd04 100644 --- a/src/app/main.css +++ b/src/app/main.css @@ -2,14 +2,14 @@ --font-primary: -apple-system, BlinkMacSystemFont, 'Open Sans', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; --gray-e5: #e5e5e5; --gray-9: #999; - --purple-base: #654FF0; + --purple-base: #dbeeff; --white-base: #fff; --lightblue: #f3fcff; --lightgray: lightgray; --darkblue: darkblue; - --link-blue: rgb(0, 0, 238); + --link-blue: #e6f3ff; --black-base: black; - --light-base: #1fd1f9; + --light-base: #eaf6ff; --pink-base: deeppink; --purple-dark: #b621fe; --purple-gradient: linear-gradient(315deg, var(--light-base) 0%, var(--purple-dark) 74%); @@ -18,7 +18,7 @@ --low: 1; --medium: 2; --high: 3; - --shadow-base: 0 0 0 3px rgba(0, 123, 255, .5); + --shadow-base: 0 0 0 4px rgba(138,180,250,.16); } * { @@ -50,6 +50,116 @@ body { } a:hover, -a:focus { + a:focus { color: var(--purple-base); } + +/* ---- Dark theme overrides ---- */ +.theme-dark { + background-color: #0d1117; + color: #9ec1ff; +} + +.theme-dark a, .theme-dark a:visited { + color: #dbeeff; + transition: color .18s ease, text-shadow .18s ease; +} + +.theme-dark a:hover, .theme-dark a:focus { + color: #eaf6ff; + text-shadow: 0 6px 18px rgba(230,245,255,.06); +} + +.theme-dark .container { + background-color: #0d1117; +} + +/* Header */ +.theme-dark .header__container { + background-image: none; + background-color: #0d1117; + border-bottom: 1px solid #30363d; + box-shadow: 0 2px 8px rgba(0,0,0,.4); +} + +.theme-dark .header__link--name { + color: #e6f3ff; + transition: color .18s ease, text-shadow .18s ease; + font-weight: 500; + letter-spacing: .4px; +} + +.theme-dark .header__link:hover .header__link--name, +.theme-dark .header__link:focus .header__link--name { + color: #79b8ff; +} + +/* Search */ +.theme-dark .search-input__container { filter: drop-shadow(0 2px 12px rgba(0,0,0,.35)); } + +.theme-dark .search-input { + background-color: #0f1419; + border: 1px solid #23292f; + color: #dbeeff; + transition: border-color .18s ease, box-shadow .18s ease, background-color .18s ease; +} + +.theme-dark .search-input:focus { box-shadow: 0 8px 24px rgba(2,6,23,.36); } +.theme-dark .search-input::placeholder { color: #5f6b77; } + +/* Repo list and cards */ +.theme-dark .repo-list { color: #9ec1ff; } +.theme-dark .repos-list__container { background: transparent; } +.theme-dark .repo-list--item { border-top: none; background: #0f1419; border-radius: 12px; box-shadow: 0 6px 20px rgba(2,6,23,.28); transition: background-color .18s ease, transform .12s ease, box-shadow .18s ease; } +.theme-dark .repo-list--item:hover { background-color: rgba(230,245,255,.02); box-shadow: 0 18px 40px rgba(2,6,23,.48); transform: translateY(-6px); } +.theme-dark .repo-card__name--link { color: #dbeeff; transition: color .18s ease, text-shadow .18s ease; } +.theme-dark .repo-card__name--link:hover, .theme-dark .repo-card__name--link:focus { + color: #eaf6ff; text-decoration: underline; +} +.theme-dark .repo-card__card { background: #0f1419; box-shadow: 0 6px 28px rgba(2,6,23,.38); } +.theme-dark .repo-card__badge { background: rgba(234,246,255,.03); color: #cfe8ff; border: 1px solid rgba(234,246,255,.04); } +.theme-dark .repo-card__description { color: #8b949e; } +.theme-dark .repo-card__name--link { color: #eaf6ff; } +.theme-dark .icon--gray { color: #8b949e; } + +/* Paginator */ +.theme-dark .paginator__button { + background: #0f1419; + color: #dbeeff; + border: 1px solid #23292f; + transition: background-color .18s ease, box-shadow .18s ease, transform .12s ease; +} + +.theme-dark .paginator__button:enabled:hover, +.theme-dark .paginator__button:enabled:focus { background-color: #1f2630; box-shadow: 0 8px 24px rgba(0,0,0,.35), var(--shadow-base); } +.theme-dark .paginator__button:active { transform: translateY(1px); } + +.theme-dark .paginator__button--pressed { + background-color: #2b5f8f; + background-image: none; + color: #fff !important; + border-color: #2b5f8f; + box-shadow: 0 8px 24px rgba(43,95,143,.28); +} + +/* Footer */ +.theme-dark .footer { border-top: 1px solid #30363d; color: #8b949e; } +.theme-dark .footer__link { color: #dbeeff; } + +/* Language selector */ +.theme-dark .language-selector { + background-color: #0f1419; + color: #cfe8ff; + border: 1px solid #23292f; + transition: border-color .18s ease, box-shadow .18s ease; +} + +/* Scroller */ +.theme-dark .scroller { box-shadow: 0 8px 24px rgba(0,0,0,.35); } + +/* Global interactive focus rings */ +.theme-dark a:focus, +.theme-dark button:focus, +.theme-dark input:focus, +.theme-dark select:focus, +.theme-dark textarea:focus { box-shadow: var(--shadow-base); outline: 0; } diff --git a/src/components/Header/index.css b/src/components/Header/index.css index cd7a3d1..e8c4a8e 100644 --- a/src/components/Header/index.css +++ b/src/components/Header/index.css @@ -14,6 +14,13 @@ color: var(--white-base); } +.header__title { + font-size: 2.4rem; + font-weight: 700; + margin-left: 1rem; + letter-spacing: 0.6px; +} + .header__image { height: 100%; width: 100%; @@ -23,3 +30,34 @@ .header__link:focus .header__link--name { color: var(--gray-e5); } + +.language-selector__container { + position: absolute; + right: 12rem; + top: 1.6rem; + font-weight: 100; + font-size: 1.6rem; + border-radius: 50%; +} + +.language-selector { + margin-left: 1rem; + height: 3rem; + padding: 0 1.5rem; + cursor: pointer; + background-color: transparent; + color: var(--white-base); + border: 0.2px solid rgba(255, 255, 255, 0.12); + border-radius: 10rem; +} + +.language-selector option { + outline: 0; + background-color: transparent; + color: var(--white-base); +} + +.language-selector:focus { + box-shadow: var(--shadow-base); + outline: 0; +} diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 8bc9b96..62fa649 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -9,7 +9,7 @@ const Header = forwardRef((props, ref) => ( -

Dashboard

+

Reporigger

)); diff --git a/src/components/MainPage/index.tsx b/src/components/MainPage/index.tsx index b4cb603..487c293 100644 --- a/src/components/MainPage/index.tsx +++ b/src/components/MainPage/index.tsx @@ -10,7 +10,7 @@ const MainPage = forwardRef((props, ref) => ( - + )); diff --git a/src/components/RepoCard/index.css b/src/components/RepoCard/index.css index 2cac695..ab150b2 100644 --- a/src/components/RepoCard/index.css +++ b/src/components/RepoCard/index.css @@ -1,9 +1,46 @@ .repo-card__container { font-weight: 200; overflow-wrap: break-word; - margin-top: 1.2rem; + margin-top: 0; position: relative; - font-weight: 200; + display: flex; + flex-direction: column; + gap: 0.6rem; + width: 100%; + height: 100%; +} + +.repo-card__inner { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: flex-start; + flex: 1; + overflow: auto; +} + +.repo-card__left { + display: flex; + flex-direction: column; + gap: 0.2rem; + align-items: flex-start; +} + +.repo-card__title-wrap { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.repo-card__meta { + display: flex; + gap: 1.2rem; + align-items: center; + font-size: 1.4rem; + color: var(--gray-9); + white-space: normal; + flex-wrap: wrap; + row-gap: 0.6rem; } .repo-card__name { @@ -17,12 +54,19 @@ .repo-card__name--link { color: var(--black-base); - font-weight: 300; + font-weight: 700; + font-size: 2rem; } .repo-card__name--title { - margin-bottom: 1rem; - font-weight: 500; + margin-bottom: 0.2rem; + font-weight: 700; +} + +.repo-card__description { + color: #9aa4b2; + font-size: 1.4rem; + max-width: 56rem; } .repo-card__name--link:hover, @@ -39,15 +83,91 @@ } .repo-card__point--last-edited { - position: relative; - left: 1.9rem; - font-size: 1.5rem; + font-size: 1.3rem; } .repo-card__image--octocat { - --side-size: 1.7rem; + --side-size: 2rem; width: var(--side-size); height: var(--side-size); - margin-right: 0.4rem; - transform: translateY(3px); + margin-right: 0.6rem; +} + +.repo-card__footer { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-top: auto; + padding-top: 0.6rem; + flex-wrap: wrap; + gap: 0.6rem; } + +.repo-card__badges { + display: flex; + gap: 0.6rem; + align-items: center; + flex-wrap: wrap; +} + +.repo-card__badge { + background: rgba(2,6,23,.04); + color: #374151; + padding: 0.25rem 0.6rem; + border-radius: 999px; + font-size: 1.2rem; + font-weight: 600; +} + +.repo-card__flag { + padding: 0.25rem 0.6rem; + border-radius: 999px; + font-size: 1.2rem; + font-weight: 700; + color: #fff; +} + +.repo-card__flag--red { background: #ef4444; } +.repo-card__flag--yellow { background: #f59e0b; color: #111; } +.repo-card__flag--blue { background: #3b82f6; } +.repo-card__flag--green { background: #10b981; } +.repo-card__flag--violet { background: #8b5cf6; } + +.repo-card__language { + display: inline-block; + margin-top: 0.35rem; + margin-bottom: 0.6rem; + background: rgba(2,6,23,.06); + color: var(--white-base); + padding: 0.2rem 0.5rem; + border-radius: 8px; + font-size: 1.2rem; + font-weight: 700; +} + +.repo-card__language::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 6px; + vertical-align: middle; + background: linear-gradient(90deg, rgba(88,166,255,1), rgba(138,180,250,1)); +} + + +.repo-card__card { + padding: 1.2rem 1.4rem; + border-radius: 10px; + background: var(--white-base); + box-shadow: 0 8px 20px rgba(2,6,23,.06); + transition: transform .15s ease, box-shadow .18s ease; + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + overflow: hidden; +} + +.repo-card__card:hover { transform: translateY(-4px); box-shadow: 0 18px 40px rgba(2,6,23,.10); } diff --git a/src/components/RepoCard/index.tsx b/src/components/RepoCard/index.tsx index c2311ea..85595b7 100644 --- a/src/components/RepoCard/index.tsx +++ b/src/components/RepoCard/index.tsx @@ -1,4 +1,4 @@ -import React, { memo } from 'react'; +import React, { memo, useState, useEffect, useRef } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { shallowEqual, useSelector } from 'react-redux'; @@ -8,58 +8,205 @@ import { RootState } from 'app/rootReducer'; import 'components/RepoCard/index.css'; const RepoCard = memo(({ - id, name, stargazers_count, updated_at, html_url, + id, name, stargazers_count, updated_at, html_url, description, owner, contributors, languages, language, }: Partial) => { const { t } = useTranslation(); const currentLocale = useSelector((state: RootState) => state.i18n.currentLocale); + // Use primary language provided by search results (language) or fallback to languages array + const primaryLang = language || (languages && languages.length > 0 ? languages[0] : undefined); + + const stackKeywords = ['JavaScript', 'TypeScript', 'React', 'Node', 'Python', 'Go', 'Ruby']; + const compatible = primaryLang && stackKeywords.includes(primaryLang) ? 'Likely' : 'Unknown'; + + const contributorsCount = contributors ? contributors.length : undefined; + const communityHealth = (stargazers_count && contributorsCount) ? Math.min(100, Math.round((contributorsCount / Math.max(1, stargazers_count)) * 100)) : undefined; + + // Determine a colored flag representing repo health / issues / status + const computeFlag = () => { + // priority checks + const text = `${name || ''} ${(description || '')}`.toLowerCase(); + if (text.includes('deprecated') || text.includes('unmaintained') || text.includes('archive')) return 'red'; + + // parse updated_at + try { + if (updated_at) { + const updated = new Date(updated_at).getTime(); + const now = Date.now(); + const days = (now - updated) / (1000 * 60 * 60 * 24); + if (days > 365) return 'red'; + if (days > 180) return 'yellow'; + if (days <= 30 && (communityHealth || 0) >= 30) return 'green'; + } + } catch (e) { + // ignore + } + + // low engagement + if ((contributorsCount === 0 || contributorsCount === undefined) && (stargazers_count || 0) < 10) return 'yellow'; + + // popular projects + if ((stargazers_count || 0) >= 5000) return 'violet'; + + // docs / examples + if (text.includes('example') || text.includes('demo') || text.includes('tutorial')) return 'blue'; + + // fallback + return 'blue'; + }; + + const flag = computeFlag(); + + // Emoji reaction flags (persisted in localStorage) + const [flags, setFlags] = useState([]); + const [pickerOpen, setPickerOpen] = useState(false); + const pickerRef = useRef(null); + + const emojiOptions = [ + { flag: 'red', emoji: '🔴', label: 'Critical' }, + { flag: 'yellow', emoji: '🟡', label: 'At risk' }, + { flag: 'blue', emoji: '���', label: 'Info' }, + { flag: 'green', emoji: '🟢', label: 'Healthy' }, + { flag: 'violet', emoji: '🟣', label: 'Popular' }, + ]; + + useEffect(() => { + // load flags for this repo + if (!id) return; + // import lazily to avoid circular deps + const { getFlagsFor } = require('utils/flags'); + const f = getFlagsFor(id); + setFlags(f); + }, [id]); + + const toggleFlag = (f: string) => { + if (!id) return; + const { toggleFlagFor } = require('utils/flags'); + const updated = toggleFlagFor(id, f as any); + setFlags(updated); + setPickerOpen(false); + }; + + // close picker when clicking outside + useEffect(() => { + const onDoc = (e: MouseEvent) => { + if (!pickerOpen) return; + if (!pickerRef.current) return; + if (!(e.target instanceof Node)) return; + if (!pickerRef.current.contains(e.target)) setPickerOpen(false); + }; + document.addEventListener('click', onDoc); + return () => document.removeEventListener('click', onDoc); + }, [pickerOpen]); + + return (
-
-

- {id ? ( - - {name} - - ) : name} -

- {stargazers_count !== undefined - && stargazers_count >= 0 && ( -
- -   - {stargazers_count.toLocaleString(currentLocale)} +
+
+
+
+

+ {html_url ? ( + + {name} + + ) : (id ? ( + + {name} + + ) : name)} +

+ {primaryLang && {primaryLang}} + {description &&
{description}
} +
+
+ +
+ {stargazers_count !== undefined && stargazers_count >= 0 && ( +
+ +   + {stargazers_count.toLocaleString(currentLocale)} +
+ )} + {contributorsCount !== undefined && ( +
+ 👥 +   + {contributorsCount} +
+ )} + {communityHealth !== undefined && ( +
+ Community: {communityHealth}% +
+ )} + {updated_at && ( + + {`${t('last_update')}:`} +   + {`${formatDate(updated_at, currentLocale)}`} + + )} +
+
+ +
+
+ {/* Emoji flag reactions */} +
+ {flags && flags.map((f) => ( + + ))} + + + {pickerOpen && ( +
+ {emojiOptions.map((opt) => ( + + ))}
- )} - {updated_at && ( - - {`${t('last_update')}:`} -   - {`${formatDate(updated_at, currentLocale)}`} - - )} - {html_url && ( - + {flag && { + flag === 'red' ? 'Critical' : flag === 'yellow' ? 'At risk' : flag === 'green' ? 'Healthy' : flag === 'violet' ? 'Popular' : 'Info' + }} + Compatible: {compatible} + {primaryLang && {primaryLang}} + {!primaryLang && languages && languages.slice(0,3).map((l) => {l})} +
+ + {html_url && ( + + )}
- )}
-
); }, shallowEqual); diff --git a/src/components/Scroller/index.tsx b/src/components/Scroller/index.tsx index fe35863..413f8e5 100644 --- a/src/components/Scroller/index.tsx +++ b/src/components/Scroller/index.tsx @@ -1,21 +1,6 @@ -import React, { forwardRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import 'components/Scroller/index.css'; +import React from 'react'; -const Scroller = forwardRef((props, ref) => { - const { t } = useTranslation(); - - if (!ref) { - return null; - } - - const onClick = () => { - // FIXME - // @ts-ignore - ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); - }; - - return ; -}); +// Scroller intentionally disabled — removed scroll-to-top control per UX request. +const Scroller = () => null; export default Scroller; diff --git a/src/features/pagination/Paginator.css b/src/features/pagination/Paginator.css index a1ac5a0..7991abb 100644 --- a/src/features/pagination/Paginator.css +++ b/src/features/pagination/Paginator.css @@ -1,42 +1,49 @@ .paginator__container { - padding: 1rem 0; + padding: 1.5rem 0; display: flex; justify-content: center; - font-weight: 200; + font-weight: 400; position: relative; + gap: 0.4rem; } .paginator__button { - line-height: 1.5; + line-height: 1; text-align: center; - padding: 0.7rem 1.2rem; - color: var(--purple-base); + padding: 6px 10px; + color: var(--link-blue); cursor: pointer; user-select: none; - background: var(--white-base); - border: 1px solid var(--gray-e5); + background: transparent; + border: 1px solid rgba(27,31,35,0.12); + border-radius: 6px; + min-width: 36px; + height: 32px; + font-size: 13px; } .paginator__button:enabled:hover, .paginator__button:enabled:focus { - background-color: var(--lightblue); + background-color: rgba(27,31,35,0.04); + box-shadow: none; } .paginator__button:disabled { color: var(--lightgray); cursor: default; + opacity: 0.6; } .paginator__button--pressed { - color: var(--white-base) !important; - border: 1px solid var(--lightgray); - background-color: var(--purple-base); - background-image: var(--purple-gradient); + background: var(--link-blue); + color: white !important; + border-color: var(--link-blue); + box-shadow: 0 2px 6px rgba(0,0,0,0.12); } -.paginator__button--pressed:hover { - background-image: var(--reverse-purple-gradient); -} +/* make prev/next have chevrons similar to GitHub */ +.paginator__button.prev::before { content: '<'; margin-right: 6px; } +.paginator__button.next::after { content: '>'; margin-left: 6px; } @media (max-width: 675px) { .paginator__button:not(:first-child):not(:last-child):not(.paginator__button--pressed) { diff --git a/src/features/pagination/Paginator.tsx b/src/features/pagination/Paginator.tsx index fb877eb..14a9bd0 100644 --- a/src/features/pagination/Paginator.tsx +++ b/src/features/pagination/Paginator.tsx @@ -56,19 +56,21 @@ const Paginator = () => { totalPages > 1 ? (
{paginator} diff --git a/src/features/repoDetails/RepoDetails.tsx b/src/features/repoDetails/RepoDetails.tsx index 4be0102..3c6d5d1 100644 --- a/src/features/repoDetails/RepoDetails.tsx +++ b/src/features/repoDetails/RepoDetails.tsx @@ -96,7 +96,7 @@ const RepoDetails = memo(forwardRef((props, ref) => { )} - + )}
diff --git a/src/features/reposList/RepoList.css b/src/features/reposList/RepoList.css index c71c807..de47db9 100644 --- a/src/features/reposList/RepoList.css +++ b/src/features/reposList/RepoList.css @@ -1,20 +1,29 @@ .repo-list { list-style: none; padding: 1rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(24rem, 1fr)); + gap: 1.4rem; } .repos-list__container { - width: 50%; + width: 100%; } .repo-list--item { + display: flex; + align-items: stretch; + justify-content: center; padding: 1rem; - border-top: 1px solid var(--gray-e5); + border-top: none; + aspect-ratio: 1 / 1; + min-height: 24rem; + overflow: hidden; } -.repo-list--item:first-child { - padding: 0 1rem; - border-top: none; +.repo-list--item:hover .repo-card__card { + transform: translateY(-6px); + box-shadow: 0 14px 40px rgba(2,6,23,.12); } .repos-list__info--empty { @@ -23,12 +32,14 @@ @media (max-width: 768px) { .repos-list__container { - width: 70%; + width: 100%; + padding: 0 1rem; } } @media (max-width: 525px) { .repos-list__container { width: 100%; + padding: 0 0.5rem; } } diff --git a/src/features/reposList/ReposList.tsx b/src/features/reposList/ReposList.tsx index a596d85..6f81908 100644 --- a/src/features/reposList/ReposList.tsx +++ b/src/features/reposList/ReposList.tsx @@ -14,7 +14,7 @@ const ReposList = (): JSX.Element => { const renderedRepos = useMemo(() => repos.length > 0 && (