diff --git a/.github/workflows/pr-source-check.yml b/.github/workflows/pr-source-check.yml new file mode 100644 index 00000000..42a6c297 --- /dev/null +++ b/.github/workflows/pr-source-check.yml @@ -0,0 +1,18 @@ +name: pr-source-check + +on: + pull_request: + branches: [main] + +jobs: + pr-source-check: + runs-on: ubuntu-latest + steps: + - name: Enforce source = test for PRs into main + run: | + if [ "${{ github.event.pull_request.head.ref }}" != "test" ]; then + echo "::error::PRs into main must originate from 'test'. Head is '${{ github.event.pull_request.head.ref }}'." + echo "::error::Open your PR against 'test' instead. main only advances via the Release workflow." + exit 1 + fi + echo "PR source is 'test' — allowed." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..a70f9e4c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,80 @@ +name: Release (promote test → main) + +on: + workflow_dispatch: + inputs: + dry_run: + description: 'Dry run — show what would happen, do not push' + type: boolean + default: false + workflow_call: + inputs: + dry_run: + type: boolean + default: false + +permissions: + contents: write + +concurrency: + group: release-${{ github.repository }} + cancel-in-progress: false + +jobs: + promote: + runs-on: ubuntu-latest + steps: + - name: Checkout (full history) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: test + token: ${{ secrets.RELEASE_TOKEN }} + + - name: Verify main is ancestor of test + run: | + git fetch origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor origin/main origin/test; then + echo "::error::main is not an ancestor of test. Reconcile manually before promoting." + exit 1 + fi + ahead=$(git rev-list --count origin/main..origin/test) + echo "main is behind test by $ahead commit(s). Ready to fast-forward." + echo "AHEAD=$ahead" >> $GITHUB_ENV + + - name: Tag release + run: | + TAG="release-$(date -u +%Y%m%d-%H%M%S)" + git tag "$TAG" origin/test + echo "TAG=$TAG" >> $GITHUB_ENV + + - name: Fast-forward main → test + if: ${{ !inputs.dry_run }} + run: | + git push origin "refs/remotes/origin/test:refs/heads/main" + git push origin "$TAG" + echo "::notice::Promoted $AHEAD commits to main. Tagged $TAG." + + - name: Notify Discord + if: ${{ !inputs.dry_run && env.DISCORD_WEBHOOK != '' }} + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }} + run: | + repo="${{ github.repository }}" + sha=$(git rev-parse origin/test) + short_sha="${sha:0:7}" + compare_url="https://github.com/${repo}/compare/${TAG}...main" + tag_url="https://github.com/${repo}/releases/tag/${TAG}" + payload=$(jq -n \ + --arg content "🚀 **${repo}** released \`${TAG}\` — promoted **${AHEAD}** commit(s) to \`main\` (\`${short_sha}\`)." \ + --arg tag "$tag_url" \ + --arg cmp "$compare_url" \ + '{content: $content, embeds: [{title: "View tag", url: $tag}, {title: "Diff", url: $cmp}]}') + curl -sS -H "Content-Type: application/json" -X POST -d "$payload" "$DISCORD_WEBHOOK" + + - name: Dry run summary + if: ${{ inputs.dry_run }} + run: | + echo "DRY RUN — would fast-forward main to $(git rev-parse origin/test)" + echo "Tag would be: $TAG" + git log --oneline origin/main..origin/test | head -50 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..2312dc58 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +npx lint-staged diff --git a/lint-staged.config.mjs b/lint-staged.config.mjs new file mode 100644 index 00000000..c4327b54 --- /dev/null +++ b/lint-staged.config.mjs @@ -0,0 +1,28 @@ +import path from 'path'; +import process from 'process'; + +function toRelativePaths(filenames) { + const cwd = process.cwd(); + return filenames.map((f) => path.relative(cwd, f)); +} + +function buildEslintCommand(filenames) { + const files = toRelativePaths(filenames).join(' '); + return `eslint --fix --max-warnings 0 ${files}`; +} + +function buildPrettierCommand(filenames) { + return `prettier --write ${filenames.join(' ')}`; +} + +export default { + '*.{ts,tsx}': (filenames) => [ + buildEslintCommand(filenames), + buildPrettierCommand(filenames), + ], + '*.{js,jsx,mjs}': (filenames) => [ + buildEslintCommand(filenames), + buildPrettierCommand(filenames), + ], + '*.{json,css,html,md}': (filenames) => [buildPrettierCommand(filenames)], +}; diff --git a/package-lock.json b/package-lock.json index ed783361..1b1b93cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,6 @@ "@types/react-syntax-highlighter": "^15.5.13", "axios": "^1.7.2", "date-fns": "^4.1.0", - "dayjs": "^1.11.12", "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", "github-markdown-css": "^5.8.1", @@ -44,6 +43,8 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", "eslint-plugin-unused-imports": "^4.3.0", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", "prettier": "^3.2.5", "typescript": "^5.5.4", "vite": "^5.3.1", @@ -2509,6 +2510,22 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2722,6 +2739,39 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -2751,6 +2801,13 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2773,6 +2830,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2833,12 +2900,6 @@ "url": "https://github.com/sponsors/kossnocorp" } }, - "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2968,6 +3029,13 @@ "react": "^15.0.0 || >=16.0.0" } }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -2980,6 +3048,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -3343,6 +3424,13 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -3553,6 +3641,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3936,6 +4037,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -4071,6 +4188,22 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -4211,6 +4344,64 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/lint-staged": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", + "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.3", + "listr2": "^9.0.5", + "picomatch": "^4.0.3", + "string-argv": "^0.3.2", + "tinyexec": "^1.0.4", + "yaml": "^2.8.2" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=20.17" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -4234,6 +4425,85 @@ "dev": true, "license": "MIT" }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/longest-streak": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", @@ -5165,6 +5435,19 @@ "node": ">= 0.6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -5243,6 +5526,22 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -5879,6 +6178,23 @@ "node": ">=4" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -5890,6 +6206,13 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -6034,12 +6357,55 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/size-sensor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz", "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", "license": "ISC" }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -6083,6 +6449,62 @@ "dev": true, "license": "MIT" }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, + "node_modules/string-width": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", + "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -6193,9 +6615,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", + "integrity": "sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==", "dev": true, "license": "MIT", "engines": { @@ -7191,6 +7613,84 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index dffbd306..c5f70342 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "format:check": "prettier --check \"src/**/*.{ts,tsx,js,jsx,json,css,scss,md}\"", "preview": "vite preview --host 0.0.0.0", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "prepare": "husky" }, "dependencies": { "@emotion/react": "^11.11.4", @@ -25,7 +26,6 @@ "@types/react-syntax-highlighter": "^15.5.13", "axios": "^1.7.2", "date-fns": "^4.1.0", - "dayjs": "^1.11.12", "echarts": "^6.0.0", "echarts-for-react": "^3.0.5", "github-markdown-css": "^5.8.1", @@ -51,6 +51,8 @@ "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", "eslint-plugin-unused-imports": "^4.3.0", + "husky": "^9.1.7", + "lint-staged": "^16.4.0", "prettier": "^3.2.5", "typescript": "^5.5.4", "vite": "^5.3.1", diff --git a/src/api/DashboardApi.ts b/src/api/DashboardApi.ts index ca28f59e..3121ee69 100644 --- a/src/api/DashboardApi.ts +++ b/src/api/DashboardApi.ts @@ -2,8 +2,6 @@ import { useApiQuery } from './ApiUtils'; import { useInfiniteQuery } from '@tanstack/react-query'; import axios from 'axios'; import { - type RepoChanges, - type CommitsTrend, type Stats, type Repository, type LanguageWeight, @@ -27,16 +25,6 @@ export const useDashboardQuery = ( export const useStats = () => useDashboardQuery('useStats', '/stats'); -export const useHistoricalTrend = () => - useDashboardQuery('useHistoricalTrend', '/lines/hist-trend'); - -export const useRepoChanges = (options?: { refetchInterval?: number }) => - useDashboardQuery( - 'useRepoChanges', - '/repos/commits', - options?.refetchInterval, - ); - // Shared cache key for the repositories dataset. export const getReposQueryKey = () => ['useReposAndWeights', '/dash/repos', undefined] as const; diff --git a/src/api/IssuesApi.ts b/src/api/IssuesApi.ts index 9b026046..f0f824ce 100644 --- a/src/api/IssuesApi.ts +++ b/src/api/IssuesApi.ts @@ -51,18 +51,6 @@ export const useRepoIssues = (repoFullName: string) => export const useIssuesStats = () => useApiQuery('useIssuesStats', '/issues/stats'); -/** - * Fetch a single issue by ID. - */ -export const useIssue = (id: number) => - useApiQuery( - 'useIssue', - `/issues/${id}`, - undefined, - undefined, - !!id, - ); - /** * Fetch issue details with GitHub data. */ diff --git a/src/api/ReposApi.ts b/src/api/ReposApi.ts index 33155c6c..70c8efce 100644 --- a/src/api/ReposApi.ts +++ b/src/api/ReposApi.ts @@ -1,7 +1,7 @@ // Repository API hooks - uses /repos endpoints import { useApiQuery } from './ApiUtils'; import { type RepositoryMaintainer, type RepositoryIssue } from './models'; -import { type CommitLog, type Repository } from './models/Dashboard'; +import { type Repository } from './models/Dashboard'; /** * Helper to create /repos endpoint queries @@ -48,16 +48,3 @@ export const useRepositoryIssues = (repo: string) => 'useRepositoryIssues', `/${encodeURIComponent(repo)}/issues`, ); - -/** - * Get pull requests for a specific repository filtered by state - * @param repo - Full repository name (e.g., "opentensor/btcli") - * @param state - Optional filter: "open", "closed", "merged" - */ -export const useRepositoryPRs = (repo: string, state?: string) => - useReposQuery( - 'useRepositoryPRs', - `/${encodeURIComponent(repo)}/prs`, - undefined, - state ? { state } : undefined, - ); diff --git a/src/api/SearchApi.ts b/src/api/SearchApi.ts index 50a0f511..da01e3a8 100644 --- a/src/api/SearchApi.ts +++ b/src/api/SearchApi.ts @@ -8,6 +8,7 @@ import { getReposQueryKey } from './DashboardApi'; import { getIssuesQueryKey } from './IssuesApi'; import { getAllMinersQueryKey } from './MinerApi'; import { getAllPrsQueryKey } from './PrsApi'; +import { type DatasetState } from './models'; import { type IssueBounty } from './models/Issues'; import { type CommitLog, @@ -15,12 +16,6 @@ import { type Repository, } from './models/Dashboard'; -type DatasetState = { - data: T[]; - isLoading: boolean; - isError: boolean; -}; - type SearchDatasets = { miners: DatasetState; repositories: DatasetState; diff --git a/src/api/models/Dashboard.ts b/src/api/models/Dashboard.ts index dfc991fb..f12018fb 100644 --- a/src/api/models/Dashboard.ts +++ b/src/api/models/Dashboard.ts @@ -76,6 +76,7 @@ export type CommitLog = { commitCount: number; repository: string; mergedAt: string | null; + closedAt: string | null; prCreatedAt: string; prState: string; collateralScore?: string; @@ -104,6 +105,11 @@ export type CommitLog = { // Review quality reviewQualityMultiplier?: string; + // Label scoring + labelMultiplier?: number; + label?: string; + codeDensity?: number; + // Payout predictions potentialScore?: number; predictedAlphaPerDay?: number | null; @@ -210,6 +216,9 @@ export type PullRequestDetails = { timeDecayMultiplier: string; // float returned as string credibilityMultiplier: string; // float returned as string reviewQualityMultiplier?: string; // float returned as string + labelMultiplier: number; + label: string | null; + codeDensity: number; earnedScore: string; // float returned as string collateralScore: string; // float returned as string additions: number; diff --git a/src/api/models/DatasetState.ts b/src/api/models/DatasetState.ts new file mode 100644 index 00000000..2f07c93d --- /dev/null +++ b/src/api/models/DatasetState.ts @@ -0,0 +1,5 @@ +export type DatasetState = { + data: T[]; + isLoading: boolean; + isError: boolean; +}; diff --git a/src/api/models/Issues.ts b/src/api/models/Issues.ts index 4715179e..fc2e72b9 100644 --- a/src/api/models/Issues.ts +++ b/src/api/models/Issues.ts @@ -15,6 +15,7 @@ export interface IssueBounty { registeredAtBlock: number; createdAt: string; updatedAt: string; + closedAt: string | null; completedAt: string | null; title?: string; } diff --git a/src/api/models/index.ts b/src/api/models/index.ts index e56a2f6c..e147ef1a 100644 --- a/src/api/models/index.ts +++ b/src/api/models/index.ts @@ -1,4 +1,5 @@ export * from './Dashboard'; +export * from './DatasetState'; export * from './Issues'; export * from './Miner'; export * from './Configurations'; diff --git a/src/components/dashboard/ContributionHeatmap.tsx b/src/components/ContributionHeatmap.tsx similarity index 76% rename from src/components/dashboard/ContributionHeatmap.tsx rename to src/components/ContributionHeatmap.tsx index edda4e3b..8ad33fcd 100644 --- a/src/components/dashboard/ContributionHeatmap.tsx +++ b/src/components/ContributionHeatmap.tsx @@ -1,6 +1,11 @@ import React from 'react'; -import { Box, Card, Typography, Tooltip } from '@mui/material'; +import { Box, Card, Typography, Tooltip, alpha, useTheme } from '@mui/material'; import { ActivityCalendar } from 'react-activity-calendar'; +import { + CONTRIBUTION_HEATMAP_SCALE, + TEXT_OPACITY, + scrollbarSx, +} from '../theme'; interface ContributionData { date: string; @@ -19,11 +24,6 @@ interface ContributionHeatmapProps { bare?: boolean; } -const HEATMAP_THEME = { - light: ['#161b22', '#0e4429', '#006d32', '#26a641', '#39d353'], - dark: ['#161b22', '#0e4429', '#006d32', '#26a641', '#39d353'], -}; - const ContributionHeatmap: React.FC = ({ data, contributionsLast30Days, @@ -34,6 +34,9 @@ const ContributionHeatmap: React.FC = ({ emptySubtitle = 'Activity will appear here once PRs are merged', bare = false, }) => { + const theme = useTheme(); + const heatmapLevels = [...CONTRIBUTION_HEATMAP_SCALE]; + const heatmapTheme = { light: heatmapLevels, dark: heatmapLevels }; const isEmpty = data.length === 0; const content = ( @@ -41,8 +44,7 @@ const ContributionHeatmap: React.FC = ({ = ({ = ({ - + {isEmpty ? ( = ({ > = ({ {emptySubtitle && ( = ({ ) : ( = ({ blockSize={11} blockMargin={3} fontSize={11} - style={{ color: '#fff' }} + style={{ color: theme.palette.text.primary }} showWeekdayLabels={false} renderBlock={(block, activity) => ( {block} @@ -144,7 +154,7 @@ const ContributionHeatmap: React.FC = ({ { + state: ErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo) { + // TODO: forward to Sentry / Datadog when telemetry is wired up. + console.error('[ErrorBoundary]', error, info.componentStack); + } + + componentDidUpdate(prev: ErrorBoundaryProps) { + if (this.state.error && prev.resetKey !== this.props.resetKey) { + this.setState({ error: null }); + } + } + + private handleReset = () => this.setState({ error: null }); + + render() { + if (this.state.error) { + return ( + + ); + } + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/src/components/ErrorFallback.tsx b/src/components/ErrorFallback.tsx new file mode 100644 index 00000000..d4bc8d02 --- /dev/null +++ b/src/components/ErrorFallback.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { Box, Button, Stack, Typography } from '@mui/material'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import HomeIcon from '@mui/icons-material/Home'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import { STATUS_COLORS, scrollbarSx } from '../theme'; + +interface ErrorFallbackProps { + variant: 'fullPage' | 'inline'; + error: Error; + onReset: () => void; +} + +const ErrorFallback: React.FC = ({ + variant, + error, + onReset, +}) => { + const goHome = () => { + window.location.assign('/dashboard'); + }; + + const container = + variant === 'fullPage' + ? { + minHeight: '100vh', + width: '100%', + backgroundColor: 'background.default', + px: { xs: 3, md: 6 }, + py: { xs: 6, md: 10 }, + } + : { + minHeight: '60vh', + width: '100%', + px: { xs: 2, md: 4 }, + py: { xs: 4, md: 6 }, + }; + + return ( + + + + + Something went wrong rendering this view. + + + The error has been logged. You can try this view again or return to + the dashboard. + + + {error.message} + + + + + + + + ); +}; + +export default ErrorFallback; diff --git a/src/components/FAQ.tsx b/src/components/FAQ.tsx index 16982aad..53442e71 100644 --- a/src/components/FAQ.tsx +++ b/src/components/FAQ.tsx @@ -15,12 +15,13 @@ export const FAQ: React.FC = ({ question, answer }) => { sx={{ p: 3, borderRadius: 3, - backgroundColor: 'transparent', - border: '1px solid rgba(255, 255, 255, 0.1)', + backgroundColor: 'surface.transparent', + border: '1px solid', + borderColor: 'border.light', cursor: 'pointer', transition: 'all 0.2s ease-in-out', '&:hover': { - borderColor: 'rgba(255, 255, 255, 0.3)', + borderColor: 'border.medium', }, }} onClick={() => setExpanded(!expanded)} diff --git a/src/components/FilterButton.tsx b/src/components/FilterButton.tsx new file mode 100644 index 00000000..c9666da0 --- /dev/null +++ b/src/components/FilterButton.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Button, Box } from '@mui/material'; + +interface FilterButtonProps { + label: string; + isActive: boolean; + onClick: () => void; + count?: number; + color: string; + activeTextColor?: string; +} + +const FilterButton: React.FC = ({ + label, + isActive, + onClick, + count, + color, + activeTextColor = 'text.primary', +}) => ( + +); + +export default FilterButton; diff --git a/src/components/dashboard/KpiCard.tsx b/src/components/KpiCard.tsx similarity index 75% rename from src/components/dashboard/KpiCard.tsx rename to src/components/KpiCard.tsx index f38d6a41..68784e53 100644 --- a/src/components/dashboard/KpiCard.tsx +++ b/src/components/KpiCard.tsx @@ -6,10 +6,11 @@ import { type SxProps, type Theme, useMediaQuery, + useTheme, } from '@mui/material'; -import theme from '../../theme'; +import theme from '../theme'; -interface KpiCardProps { +export interface KpiCardProps { title: string; value?: string | number; subtitle?: string; @@ -24,11 +25,12 @@ const KpiCard: React.FC = ({ variant = 'medium', sx, }) => { + const muiTheme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('sm')); const isLarge = variant === 'large'; const padding = isLarge - ? { py: isMobile ? 2 : 2.5 } - : { py: isMobile ? 1.5 : 2 }; + ? { py: isMobile ? 1.6 : 2 } + : { py: isMobile ? 1.1 : 1.35 }; const valueVariant = isLarge ? 'h2' : 'h4'; const titleSize = isLarge ? (isMobile ? 14 : 16) : isMobile ? 12 : 14; @@ -36,7 +38,7 @@ const KpiCard: React.FC = ({ value !== undefined && value !== null ? typeof value === 'string' && (value.startsWith('$') || value.includes('ل') || value.includes(',')) - ? value // Already formatted with currency/token symbol or commas + ? value : typeof value === 'number' || typeof value === 'string' ? Number(value).toLocaleString() : value @@ -46,7 +48,7 @@ const KpiCard: React.FC = ({ = ({ muiTheme.palette.text.primary, + mb: isLarge ? 0.8 : 0.35, + }} > {title} muiTheme.palette.text.primary, + my: isLarge ? (isMobile ? 0.45 : 0.8) : 0.35, fontSize: isLarge ? isMobile ? '2rem' : undefined : isMobile - ? '1.25rem' - : '1.5rem', + ? '1.2rem' + : '1.42rem', }} > {formattedValue ?? '-'} @@ -90,9 +93,9 @@ const KpiCard: React.FC = ({ {subtitle && ( muiTheme.palette.text.tertiary, + mt: isLarge ? 0.4 : 0.15, fontSize: isLarge ? (isMobile ? 12 : 14) : isMobile ? 11 : 12, }} > diff --git a/src/components/common/DataTable.tsx b/src/components/common/DataTable.tsx new file mode 100644 index 00000000..2300689c --- /dev/null +++ b/src/components/common/DataTable.tsx @@ -0,0 +1,291 @@ +import React from 'react'; +import { + Alert, + Box, + CircularProgress, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableSortLabel, + Typography, + type TableCellProps, +} from '@mui/material'; +import { type SxProps, type Theme } from '@mui/material/styles'; +import { type SystemStyleObject } from '@mui/system'; +import { LinkTableRow } from './linkBehavior'; +import { bodyCellStyle, headerCellStyle, scrollbarSx } from '../../theme'; +import { type SortOrder } from '../../utils/ExplorerUtils'; + +export type DataTableColumn = { + /** + * Stable identifier for the column. Must be unique within the `columns` + * array (used as the React key). + */ + key: string; + header: React.ReactNode; + width?: string | number; + align?: TableCellProps['align']; + renderCell: (item: T) => React.ReactNode; + headerSx?: SystemStyleObject; + cellSx?: SystemStyleObject | ((item: T) => SystemStyleObject); + /** + * Setting this makes the column sortable. The actual "first-click" order + * for this key lives in the hook (`useDataTableParams.defaultOrderOverrides`); + * DataTable only renders the hover arrow direction. + */ + sortKey?: SortKey; +}; + +export type DataTableSort = { + field: SortKey; + order: SortOrder; + onChange: (nextField: SortKey) => void; +}; + +export type DataTableProps = { + columns: DataTableColumn[]; + rows: T[]; + getRowKey: (item: T) => React.Key; + isLoading?: boolean; + isError?: boolean; + errorLabel?: string; + emptyLabel?: string; + emptyState?: React.ReactNode; + minWidth?: number | string; + /** + * When set, each row renders as an `` (native new-tab / middle-click + * support). NOTE: because the row becomes ``, cells must not contain + * nested `` elements (invalid HTML). For tables with nested anchors, + * use `onRowClick` instead. + */ + getRowHref?: (item: T) => string; + linkState?: Record; + /** + * Alternative to `getRowHref` — for tables that trigger dialogs, toggle + * expansion, or render nested `` elements in cells. Ignored when + * `getRowHref` is provided. + */ + onRowClick?: (item: T) => void; + getRowSx?: (item: T) => SystemStyleObject; + header?: React.ReactNode; + /** + * Rendered below the table body when rows are present. Use this slot + * for pagination components (the app uses several styles — pluggable). + */ + pagination?: React.ReactNode; + /** + * Rendered after the table regardless of loading / empty / error state. + * For summary bars or any controls that should persist across states. + */ + footer?: React.ReactNode; + sort?: DataTableSort; + /** + * Defaults to false. Sticky header only resolves against a scroll + * ancestor with a bounded height — the consumer must provide one. + */ + stickyHeader?: boolean; + /** + * MUI table cell padding density. Defaults to 'small' (compact cells — + * matches the historical look of the leaderboard and search tables). + * Pass 'medium' for the roomier MUI default (~16px vertical padding). + */ + size?: 'small' | 'medium'; +}; + +const containerSx: SxProps = { + overflowX: 'auto', + ...scrollbarSx, +}; + +const tableSx = { + tableLayout: 'fixed', + width: '100%', +} as const; + +const clickableRowSx: SxProps = (theme) => ({ + cursor: 'pointer', + transition: 'background-color 0.2s', + '&:hover': { + backgroundColor: theme.palette.surface.subtle, + }, +}); + +export const DataTable = ({ + columns, + rows, + getRowKey, + isLoading = false, + isError = false, + errorLabel = 'Something went wrong loading this table.', + emptyLabel = 'No results to display.', + emptyState, + minWidth, + getRowHref, + linkState, + onRowClick, + getRowSx, + header, + pagination, + footer, + sort, + stickyHeader = false, + size = 'small', +}: DataTableProps) => { + const showTable = !isLoading && !isError && rows.length > 0; + const showEmpty = !isLoading && !isError && rows.length === 0; + + return ( + <> + {header} + {isLoading ? ( + + + + ) : null} + {isError && !isLoading ? ( + + {errorLabel} + + ) : null} + {showEmpty + ? (emptyState ?? ( + + {emptyLabel} + + )) + : null} + {showTable ? ( + <> + + + + + {columns.map((column) => { + const { sortKey } = column; + const isSortable = Boolean(sortKey && sort); + const isActive = Boolean( + isSortable && sort && sort.field === sortKey, + ); + const ariaSort: TableCellProps['aria-sort'] = isSortable + ? isActive && sort + ? sort.order === 'asc' + ? 'ascending' + : 'descending' + : 'none' + : undefined; + return ( + + {sortKey && sort ? ( + sort.onChange(sortKey)} + > + {column.header} + + ) : ( + column.header + )} + + ); + })} + + + + {rows.map((item) => { + const href = getRowHref?.(item); + const customRowSx = getRowSx?.(item); + const cells = columns.map((column) => { + const customCellSx = + typeof column.cellSx === 'function' + ? column.cellSx(item) + : column.cellSx; + return ( + + {column.renderCell(item)} + + ); + }); + if (href) { + return ( + + {cells} + + ); + } + if (onRowClick) { + return ( + onRowClick(item)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onRowClick(item); + } + }} + sx={[ + clickableRowSx, + ...(customRowSx ? [customRowSx] : []), + ]} + > + {cells} + + ); + } + return ( + + {cells} + + ); + })} + +
+
+ {pagination} + + ) : null} + {footer} + + ); +}; + +export default DataTable; diff --git a/src/components/common/SearchInput.tsx b/src/components/common/SearchInput.tsx index 744d3db4..61d37acf 100644 --- a/src/components/common/SearchInput.tsx +++ b/src/components/common/SearchInput.tsx @@ -8,6 +8,9 @@ interface SearchInputProps { onChange: (value: string) => void; width?: number | string; placeholder?: string; + inputRef?: React.Ref; + autoFocus?: boolean; + onBlur?: React.FocusEventHandler; } export const SearchInput: React.FC = ({ @@ -15,12 +18,18 @@ export const SearchInput: React.FC = ({ onChange, width = 180, placeholder = 'Search...', + inputRef, + autoFocus = false, + onBlur, }) => ( onChange(e.target.value)} + onBlur={onBlur} InputProps={{ startAdornment: ( @@ -38,7 +47,6 @@ export const SearchInput: React.FC = ({ width, '& .MuiOutlinedInput-root': { color: theme.palette.text.primary, - fontFamily: theme.typography.mono.fontFamily, backgroundColor: alpha(theme.palette.background.default, 0.24), fontSize: '0.8rem', borderRadius: 2, diff --git a/src/components/common/WatchlistButton.tsx b/src/components/common/WatchlistButton.tsx new file mode 100644 index 00000000..4bde9137 --- /dev/null +++ b/src/components/common/WatchlistButton.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { IconButton, Tooltip, type SxProps, type Theme } from '@mui/material'; +import StarIcon from '@mui/icons-material/Star'; +import StarBorderIcon from '@mui/icons-material/StarBorder'; +import { useWatchlist, type WatchlistCategory } from '../../hooks/useWatchlist'; + +interface WatchlistButtonProps { + category: WatchlistCategory; + itemKey: string; + size?: 'small' | 'medium'; + sx?: SxProps; +} + +export const WatchlistButton: React.FC = ({ + category, + itemKey, + size = 'small', + sx, +}) => { + const { isWatched, toggle } = useWatchlist(category); + const watched = itemKey ? isWatched(itemKey) : false; + const label = watched ? 'Remove from watchlist' : 'Add to watchlist'; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (!itemKey) return; + toggle(itemKey); + }; + + return ( + + + {watched ? ( + + ) : ( + + )} + + + ); +}; diff --git a/src/components/common/index.ts b/src/components/common/index.ts index c068bf43..aedbfbd3 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -1 +1,4 @@ export * from './SearchInput'; +export * from './linkBehavior'; +export * from './WatchlistButton'; +export * from './DataTable'; diff --git a/src/components/common/linkBehavior.tsx b/src/components/common/linkBehavior.tsx new file mode 100644 index 00000000..1a807c87 --- /dev/null +++ b/src/components/common/linkBehavior.tsx @@ -0,0 +1,104 @@ +import { forwardRef, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Box, + TableRow, + type BoxProps, + type TableRowProps, +} from '@mui/material'; +import type { SxProps, Theme } from '@mui/material/styles'; + +type LinkState = Record | undefined; + +const isModifiedEvent = (e: React.MouseEvent): boolean => + e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0; + +/** Sx reset so an element rendered as `
` inherits color/decoration. */ +export const linkResetSx = { textDecoration: 'none', color: 'inherit' }; + +/** + * Gives any MUI element rendered with `component="a"` real `` + * semantics — middle-click, Cmd/Ctrl-click, and right-click "Open in + * new tab" work natively, while plain left-click stays a React Router + * SPA navigation. + */ +export const useLinkBehavior = ( + href: string, + options: { + state?: LinkState; + onClick?: (e: React.MouseEvent) => void; + } = {}, +) => { + const navigate = useNavigate(); + const { state, onClick } = options; + + const handleClick = useCallback( + (e: React.MouseEvent) => { + onClick?.(e); + if (e.defaultPrevented) return; + if (isModifiedEvent(e)) return; + e.preventDefault(); + navigate(href, { state }); + }, + [href, state, navigate, onClick], + ); + + return { href, onClick: handleClick } as const; +}; + +const mergeSx = (base: SxProps, extra: SxProps | undefined) => + (extra === undefined + ? base + : Array.isArray(extra) + ? [base, ...extra] + : [base, extra]) as SxProps; + +type LinkProps = { + href: string; + linkState?: LinkState; +}; + +/** + * A `Box` that renders as `` with SPA + native new-tab behavior. + * Drop-in replacement for any ` navigate(...)}>` row. + */ +export const LinkBox = forwardRef( + ({ href, linkState, sx, ...rest }, ref) => { + const linkProps = useLinkBehavior(href, { + state: linkState, + }); + return ( + + ); + }, +); +LinkBox.displayName = 'LinkBox'; + +/** + * A `TableRow` that renders as ``. Drop-in replacement for any + * ` navigate(...)}>` row. + */ +export const LinkTableRow = forwardRef< + HTMLAnchorElement, + TableRowProps & LinkProps +>(({ href, linkState, sx, ...rest }, ref) => { + const linkProps = useLinkBehavior(href, { + state: linkState, + }); + return ( + + ); +}); +LinkTableRow.displayName = 'LinkTableRow'; diff --git a/src/components/dashboard/CommitTrendChart.tsx b/src/components/dashboard/CommitTrendChart.tsx deleted file mode 100644 index 5e79a63d..00000000 --- a/src/components/dashboard/CommitTrendChart.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import React from 'react'; -import ReactECharts from 'echarts-for-react'; -import { - Card, - CardContent, - Typography, - useTheme, - useMediaQuery, -} from '@mui/material'; -import { useHistoricalTrend } from '../../api'; -import dayjs from 'dayjs'; -import utc from 'dayjs/plugin/utc'; - -dayjs.extend(utc); - -const CommitTrendChart: React.FC = () => { - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - - const { data } = useHistoricalTrend(); - - // Filter data to only include the last 10 days (using UTC) - const filteredData = Array.isArray(data) - ? data.filter((item) => - dayjs.utc(item.date).isAfter(dayjs.utc().subtract(10, 'day')), - ) - : []; - - const option = { - title: { - show: false, - }, - tooltip: { - trigger: 'axis', - formatter: (params: any) => { - const data = params[0]; - return `${dayjs - .utc(data.axisValue) - .format( - 'YYYY-MM-DD', - )} UTC
Lines Committed: ${data.value.toLocaleString()}`; - }, - backgroundColor: 'rgba(18, 18, 20, 0.95)', - borderColor: 'rgba(255, 255, 255, 0.1)', - borderWidth: 1, - textStyle: { - color: theme.palette.text.primary, - fontFamily: '"JetBrains Mono", monospace', - fontSize: isMobile ? 11 : 12, - }, - padding: [8, 12], - }, - grid: { - top: isMobile ? '8%' : '6%', - left: isMobile ? '2%' : '3%', - right: isMobile ? '2%' : '3%', - bottom: isMobile ? '8%' : '6%', - containLabel: true, - }, - xAxis: { - type: 'category', - boundaryGap: false, - data: filteredData?.map((item) => item.date), - axisLabel: { - color: theme.palette.text.secondary, - fontFamily: '"JetBrains Mono", monospace', - fontSize: isMobile ? 10 : 11, - formatter: (value: string) => { - const date = dayjs.utc(value); - return `${date.month() + 1}/${date.date()}`; - }, - margin: isMobile ? 8 : 12, - }, - axisLine: { - lineStyle: { - color: theme.palette.divider, - }, - }, - }, - yAxis: { - type: 'log', - logBase: 10, - axisLabel: { - color: theme.palette.text.secondary, - fontFamily: '"JetBrains Mono", monospace', - fontSize: isMobile ? 10 : 11, - formatter: (value: number) => { - if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`; - if (value >= 1000) return `${(value / 1000).toFixed(1)}k`; - return value.toString(); - }, - margin: isMobile ? 8 : 12, - }, - axisLine: { - lineStyle: { - color: theme.palette.divider, - }, - }, - splitLine: { - lineStyle: { - color: theme.palette.divider, - opacity: 0.3, - }, - }, - }, - series: [ - { - name: 'Lines Committed', - type: 'line', - data: filteredData?.map((item) => - typeof item.linesCommitted === 'string' - ? parseInt(item.linesCommitted) - : item.linesCommitted, - ), - smooth: true, - lineStyle: { - color: theme.palette.primary.main, - width: isMobile ? 2 : 2.5, - }, - itemStyle: { - color: theme.palette.primary.main, - }, - areaStyle: { - color: { - type: 'linear', - x: 0, - y: 0, - x2: 0, - y2: 1, - colorStops: [ - { - offset: 0, - color: `${theme.palette.primary.main}40`, - }, - { - offset: 1, - color: `${theme.palette.primary.main}10`, - }, - ], - }, - }, - emphasis: { - focus: 'series', - }, - }, - ], - }; - - return ( - - - - Lines Committed Trend - - - - - ); -}; - -export default CommitTrendChart; diff --git a/src/components/dashboard/GlobalActivity.tsx b/src/components/dashboard/GlobalActivity.tsx deleted file mode 100644 index ff6d8c59..00000000 --- a/src/components/dashboard/GlobalActivity.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import React, { useMemo } from 'react'; -import { - Box, - Card, - Typography, - Grid, - CircularProgress, - Chip, -} from '@mui/material'; -import PublicIcon from '@mui/icons-material/Public'; -import CodeOffIcon from '@mui/icons-material/CodeOff'; -import { subDays, format } from 'date-fns'; -import { useAllMiners, useAllPrs } from '../../api'; -import { STATUS_COLORS } from '../../theme'; -import ContributionHeatmap from './ContributionHeatmap'; -import PRStatusChart from './PRStatusChart'; - -const GlobalActivity: React.FC = () => { - const { data: allMinerStats, isLoading: isLoadingStats } = useAllMiners(); - const { data: allPrs, isLoading: isLoadingPRs } = useAllPrs(); - - // Calculate Heatmap Data - const { contributionData, contributionsLast30Days, totalDaysShown } = - useMemo(() => { - if (!Array.isArray(allPrs) || allPrs.length === 0) { - return { - contributionData: [], - contributionsLast30Days: 0, - totalDaysShown: 0, - }; - } - - const today = new Date(); - let earliestDate = today; - - allPrs.forEach((pr) => { - if (pr?.mergedAt) { - const d = new Date(pr.mergedAt); - if (!isNaN(d.getTime()) && d < earliestDate) earliestDate = d; - } - }); - - const diffTime = Math.abs(today.getTime() - earliestDate.getTime()); - const daysDiff = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - const daysToShow = isNaN(daysDiff) ? 1 : Math.max(daysDiff, 1); - - const dataMap = new Map(); - for (let i = daysToShow; i >= 0; i--) { - dataMap.set(format(subDays(today, i), 'yyyy-MM-dd'), 0); - } - - let last30Count = 0; - const thirtyDaysAgo = subDays(today, 30); - - allPrs.forEach((pr) => { - if (!pr?.mergedAt) return; - const date = new Date(pr.mergedAt); - if (isNaN(date.getTime())) return; - - const dateStr = format(date, 'yyyy-MM-dd'); - if (dataMap.has(dateStr)) { - dataMap.set(dateStr, (dataMap.get(dateStr) || 0) + 1); - } - if (date >= thirtyDaysAgo) last30Count++; - }); - - let maxDaily = 0; - dataMap.forEach((count) => { - if (count > maxDaily) maxDaily = count; - }); - - const data = Array.from(dataMap.entries()) - .map(([date, count]) => { - let level: 0 | 1 | 2 | 3 | 4 = 0; - if (count > 0) level = 1; - if (count >= maxDaily * 0.25) level = 2; - if (count >= maxDaily * 0.5) level = 3; - if (count >= maxDaily * 0.75) level = 4; - return { date, count, level }; - }) - .sort((a, b) => a.date.localeCompare(b.date)); - - return { - contributionData: data, - contributionsLast30Days: last30Count, - totalDaysShown: daysToShow, - }; - }, [allPrs]); - - // Calculate Active/Inactive Stats - const { activeStats, inactiveStats } = useMemo(() => { - const defaultStats = { merged: 0, open: 0, closed: 0, total: 0 }; - - if (!Array.isArray(allMinerStats)) { - return { - activeStats: { ...defaultStats, credibility: 0 }, - inactiveStats: { ...defaultStats, credibility: 0 }, - }; - } - - const active = { ...defaultStats }; - const inactive = { ...defaultStats }; - - allMinerStats.forEach((m) => { - const target = m.isEligible ? active : inactive; - - target.merged += m.totalMergedPrs || 0; - target.open += m.totalOpenPrs || 0; - target.closed += m.totalClosedPrs || 0; - target.total += 1; - }); - - return { - activeStats: { - ...active, - credibility: - active.merged + active.closed > 0 - ? active.merged / (active.merged + active.closed) - : 0, - }, - inactiveStats: { - ...inactive, - credibility: - inactive.merged + inactive.closed > 0 - ? inactive.merged / (inactive.merged + inactive.closed) - : 0, - }, - }; - }, [allMinerStats]); - - if (isLoadingPRs || isLoadingStats) { - return ( - - - - ); - } - - const hasNoData = !allPrs || allPrs.length === 0; - - return ( - - {/* Header */} - - - Global Developer Activity - - } - label="Active Network - Continuous Development" - sx={{ - color: STATUS_COLORS.success, - borderColor: `${STATUS_COLORS.success}4d`, - '& .MuiChip-icon': { color: STATUS_COLORS.success }, - }} - /> - - - {hasNoData ? ( - - - - No activity data available yet - - - ) : ( - - {/* Heatmap */} - - - - - {/* Active & Inactive Stats */} - - ({ - height: '100%', - p: { xs: 1.5, sm: 3 }, - display: 'flex', - flexDirection: 'column', - [theme.breakpoints.between('lg', 'xl')]: { padding: '16px' }, - overflow: 'visible', - })} - > - - - - - - - - )} - - ); -}; - -export default GlobalActivity; diff --git a/src/components/dashboard/LeaderboardCharts.tsx b/src/components/dashboard/LeaderboardCharts.tsx deleted file mode 100644 index 9aa659be..00000000 --- a/src/components/dashboard/LeaderboardCharts.tsx +++ /dev/null @@ -1,571 +0,0 @@ -import React, { useState, useMemo } from 'react'; -import { - Box, - Card, - Tabs, - Tab, - FormControlLabel, - Switch, - Typography, - CircularProgress, -} from '@mui/material'; -import BarChartIcon from '@mui/icons-material/BarChart'; -import ReactECharts from 'echarts-for-react'; -import { useAllPrs, useReposAndWeights } from '../../api'; -import { truncateText } from '../../utils'; - -const CHART_DOT_COLOR = 'rgba(88, 166, 255, 0.9)'; - -const LeaderboardCharts: React.FC = () => { - const [activeTab, setActiveTab] = useState(0); - const [useLogScale, setUseLogScale] = useState(true); - - const { data: allPRs, isLoading: isLoadingPRs } = useAllPrs(); - const { data: repos, isLoading: isLoadingRepos } = useReposAndWeights(); - - const isLoading = isLoadingPRs || isLoadingRepos; - const hasNoData = !allPRs || allPRs.length === 0; - - // Process top PRs - const topPRs = useMemo(() => { - if (!allPRs) return []; - return [...allPRs] - .sort((a, b) => parseFloat(b.score || '0') - parseFloat(a.score || '0')) - .slice(0, 50) - .map((pr, index) => ({ ...pr, rank: index + 1 })); - }, [allPRs]); - - // Process repo stats - const repoStats = useMemo(() => { - if (!allPRs || !repos) return []; - - const statsMap = new Map(); - allPRs.forEach((pr) => { - if (!pr || !pr.repository) return; - const current = statsMap.get(pr.repository) || { - repository: pr.repository, - totalScore: 0, - totalPRs: 0, - uniqueMiners: new Set(), - weight: 0, - }; - current.totalScore += parseFloat(pr.score || '0'); - current.totalPRs += 1; - if (pr.author) current.uniqueMiners.add(pr.author); - statsMap.set(pr.repository, current); - }); - - statsMap.forEach((stats, repoName) => { - const repoData = repos.find((r) => r.fullName === repoName); - if (repoData) { - stats.weight = repoData.weight - ? parseFloat(String(repoData.weight)) - : 0; - } - }); - - return Array.from(statsMap.values()) - .sort((a, b) => b.totalScore - a.totalScore) - .slice(0, 50) - .map((repo, index) => ({ ...repo, rank: index + 1 })); - }, [allPRs, repos]); - - const getPRsChartOption = () => { - const chartData = topPRs - .filter((item) => parseFloat(item?.score || '0') >= 1) - .slice(0, 50); - - const xAxisData = chartData.map( - (item) => `#${item?.pullRequestNumber || ''}`, - ); - const dotData = chartData.map((item) => ({ - value: Number(parseFloat(item?.score || '0')), - title: item?.pullRequestTitle || '', - author: item?.author || '', - repository: item?.repository || '', - prNumber: item?.pullRequestNumber || 0, - rank: item?.rank || 0, - itemStyle: { - color: CHART_DOT_COLOR, - shadowBlur: 10, - shadowColor: CHART_DOT_COLOR, - }, - })); - - return { - backgroundColor: 'transparent', - title: { - text: 'Pull Request Performance Ranking', - subtext: 'Individual PR scores ranked by performance', - left: 'center', - top: 20, - textStyle: { - color: '#ffffff', - fontFamily: 'JetBrains Mono', - fontSize: 18, - fontWeight: 600, - }, - subtextStyle: { - color: 'rgba(255, 255, 255, 0.6)', - fontFamily: 'JetBrains Mono', - fontSize: 11, - }, - }, - tooltip: { - trigger: 'axis', - axisPointer: { - type: 'shadow', - shadowStyle: { color: 'rgba(255, 255, 255, 0.05)' }, - }, - backgroundColor: 'rgba(10, 10, 12, 0.98)', - borderColor: 'rgba(255, 255, 255, 0.2)', - borderWidth: 1, - textStyle: { - color: '#fff', - fontFamily: 'JetBrains Mono', - fontSize: 12, - }, - padding: [14, 18], - formatter: (params: any) => { - const data = params[0]?.data || params[1]?.data; - if (!data) return ''; - return ` -
-
- -
-
PR #${data.prNumber}
-
${data.author}
-
-
-
- ${data.title} -
-
-
- Rank: - #${data.rank} -
-
- Score: - ${data.value.toFixed(4)} -
-
- Repository: - ${data.repository} -
-
-
- `; - }, - }, - grid: { - left: '3%', - right: '3%', - bottom: '2%', - top: '18%', - containLabel: true, - }, - xAxis: { - type: 'category', - data: xAxisData, - axisLabel: { - color: 'rgba(255, 255, 255, 0.85)', - fontFamily: 'JetBrains Mono', - fontSize: 11, - interval: 0, - rotate: 45, - margin: 12, - }, - axisLine: { - lineStyle: { color: 'rgba(255, 255, 255, 0.08)', width: 1 }, - }, - axisTick: { show: false }, - }, - yAxis: { - type: useLogScale ? 'log' : 'value', - min: useLogScale ? 1 : 0, - logBase: 10, - name: 'PR Score', - nameTextStyle: { - color: 'rgba(255, 255, 255, 0.85)', - fontFamily: 'JetBrains Mono', - fontSize: 12, - }, - axisLabel: { - color: 'rgba(255, 255, 255, 0.85)', - fontFamily: 'JetBrains Mono', - fontSize: 11, - formatter: (value: number) => - value < 0.01 ? value.toExponential(1) : value.toFixed(2), - }, - splitLine: { - lineStyle: { - color: 'rgba(255, 255, 255, 0.08)', - type: 'dashed', - opacity: 0.5, - }, - }, - axisLine: { show: false }, - axisTick: { show: false }, - }, - series: [ - { - name: 'Stems', - type: 'bar', - data: dotData.map((item) => ({ - ...item, - itemStyle: { - color: CHART_DOT_COLOR, - opacity: 0.4, - borderRadius: [2, 2, 0, 0], - }, - })), - barWidth: 2, - z: 1, - animationDuration: 1000, - animationEasing: 'cubicOut', - animationDelay: (idx: number) => idx * 30, - }, - { - name: 'Dots', - type: 'scatter', - data: dotData, - symbolSize: 14, - z: 2, - emphasis: { - scale: 1.5, - itemStyle: { shadowBlur: 20, borderColor: '#fff', borderWidth: 2 }, - }, - animationDuration: 1000, - animationEasing: 'cubicOut', - animationDelay: (idx: number) => idx * 30 + 100, - }, - ], - }; - }; - - const getReposChartOption = () => { - const chartData = repoStats - .filter((item) => item.totalScore >= 1) - .slice(0, 50); - - const xAxisData = chartData.map((item) => - truncateText(item.repository.split('/')[1] || item.repository, 12), - ); - const dotData = chartData.map((item) => ({ - value: item.totalScore, - repository: item.repository, - totalPRs: item.totalPRs, - uniqueMiners: item.uniqueMiners.size, - weight: item.weight, - rank: item.rank, - itemStyle: { - color: CHART_DOT_COLOR, - shadowBlur: 10, - shadowColor: CHART_DOT_COLOR, - }, - })); - - return { - backgroundColor: 'transparent', - title: { - text: 'Repository Performance Ranking', - subtext: 'Total contribution scores by repository', - left: 'center', - top: 20, - textStyle: { - color: '#ffffff', - fontFamily: 'JetBrains Mono', - fontSize: 18, - fontWeight: 600, - }, - subtextStyle: { - color: 'rgba(255, 255, 255, 0.6)', - fontFamily: 'JetBrains Mono', - fontSize: 11, - }, - }, - tooltip: { - trigger: 'axis', - axisPointer: { - type: 'shadow', - shadowStyle: { color: 'rgba(255, 255, 255, 0.05)' }, - }, - backgroundColor: 'rgba(10, 10, 12, 0.98)', - borderColor: 'rgba(255, 255, 255, 0.2)', - borderWidth: 1, - textStyle: { - color: '#fff', - fontFamily: 'JetBrains Mono', - fontSize: 12, - }, - padding: [14, 18], - formatter: (params: any) => { - const data = params[0]?.data || params[1]?.data; - if (!data) return ''; - const repoOwner = data.repository.split('/')[0]; - const repoName = data.repository.split('/')[1] || data.repository; - const avatarBg = - repoOwner === 'opentensor' - ? '#ffffff' - : repoOwner === 'bitcoin' - ? '#F7931A' - : 'transparent'; - return ` -
-
- -
-
${repoName}
-
${repoOwner}
-
-
-
-
- Rank: - #${data.rank} -
-
- Total Score: - ${data.value.toFixed(2)} -
-
- Total PRs: - ${data.totalPRs} -
-
- Contributors: - ${data.uniqueMiners} -
-
-
- `; - }, - }, - grid: { - left: '3%', - right: '3%', - bottom: '3%', - top: '18%', - containLabel: true, - }, - xAxis: { - type: 'category', - data: xAxisData, - axisLabel: { - color: 'rgba(255, 255, 255, 0.85)', - fontFamily: 'JetBrains Mono', - fontSize: 11, - interval: 0, - rotate: 45, - margin: 12, - }, - axisLine: { - lineStyle: { color: 'rgba(255, 255, 255, 0.08)', width: 1 }, - }, - axisTick: { show: false }, - }, - yAxis: { - type: useLogScale ? 'log' : 'value', - min: useLogScale ? 1 : 0, - logBase: 10, - name: 'Total Score', - nameTextStyle: { - color: 'rgba(255, 255, 255, 0.85)', - fontFamily: 'JetBrains Mono', - fontSize: 12, - }, - axisLabel: { - color: 'rgba(255, 255, 255, 0.85)', - fontFamily: 'JetBrains Mono', - fontSize: 11, - formatter: (value: number) => - value < 0.01 ? value.toExponential(1) : value.toFixed(0), - }, - splitLine: { - lineStyle: { - color: 'rgba(255, 255, 255, 0.08)', - type: 'dashed', - opacity: 0.5, - }, - }, - axisLine: { show: false }, - axisTick: { show: false }, - }, - series: [ - { - name: 'Stems', - type: 'bar', - data: dotData.map((item) => ({ - ...item, - itemStyle: { - color: CHART_DOT_COLOR, - opacity: 0.4, - borderRadius: [2, 2, 0, 0], - }, - })), - barWidth: 2, - z: 1, - animationDuration: 1000, - animationEasing: 'cubicOut', - animationDelay: (idx: number) => idx * 30, - }, - { - name: 'Dots', - type: 'scatter', - data: dotData, - symbolSize: 14, - z: 2, - emphasis: { - scale: 1.5, - itemStyle: { shadowBlur: 20, borderColor: '#fff', borderWidth: 2 }, - }, - animationDuration: 1000, - animationEasing: 'cubicOut', - animationDelay: (idx: number) => idx * 30 + 100, - }, - ], - }; - }; - - return ( - - - setActiveTab(newValue)} - sx={{ - minHeight: 'auto', - '& .MuiTab-root': { - color: 'rgba(255, 255, 255, 0.6)', - fontFamily: '"JetBrains Mono", monospace', - fontSize: '0.85rem', - fontWeight: 500, - textTransform: 'none', - minHeight: 'auto', - py: 1, - '&.Mui-selected': { color: '#fff' }, - }, - '& .MuiTabs-indicator': { backgroundColor: 'primary.main' }, - }} - > - - - - setUseLogScale(e.target.checked)} - size="small" - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { - color: 'primary.main', - }, - '& .MuiSwitch-track': { - backgroundColor: 'rgba(255, 255, 255, 0.3)', - }, - }} - /> - } - label={ - - Log Scale - - } - sx={{ ml: 'auto' }} - /> - - - {isLoading ? ( - - - - ) : hasNoData ? ( - - - - No leaderboard data available yet - - - Rankings will appear once PRs are recorded - - - ) : ( - - )} - - - ); -}; - -export default LeaderboardCharts; diff --git a/src/components/dashboard/PRStatusChart.tsx b/src/components/dashboard/PRStatusChart.tsx deleted file mode 100644 index 19da7454..00000000 --- a/src/components/dashboard/PRStatusChart.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, { useMemo } from 'react'; -import { Box, Stack, Typography } from '@mui/material'; -import ReactECharts from 'echarts-for-react'; -import { CHART_COLORS, STATUS_COLORS } from '../../theme'; - -interface PRStats { - merged: number; - open: number; - closed: number; - credibility: number; -} - -interface PRStatusChartProps { - stats: PRStats; - title: string; - subtitle?: string; - variant?: 'primary' | 'secondary'; -} - -const PRStatusChart: React.FC = ({ - stats, - title, - subtitle, - variant = 'primary', -}) => { - const { merged, open, closed, credibility } = stats; - const credibilityPercent = credibility * 100; - const isPrimary = variant === 'primary'; - - const chartOption = useMemo( - () => ({ - backgroundColor: 'transparent', - title: { - text: `${credibilityPercent.toFixed(0)}%`, - left: 'center', - top: '28%', - textStyle: { - color: isPrimary ? '#fff' : 'rgba(255, 255, 255, 0.7)', - fontSize: isPrimary ? 28 : 24, - fontWeight: 'bold', - fontFamily: '"JetBrains Mono", monospace', - textVerticalAlign: 'middle', - }, - }, - tooltip: { - trigger: 'item', - formatter: '{b}: {c} ({d}%)', - backgroundColor: 'rgba(0, 0, 0, 0.9)', - borderColor: 'rgba(255, 255, 255, 0.15)', - borderWidth: 1, - textStyle: { color: '#fff', fontFamily: '"JetBrains Mono", monospace' }, - }, - series: [ - { - name: 'PR Status', - type: 'pie', - radius: ['70%', '85%'], - center: ['50%', '42%'], - avoidLabelOverlap: false, - itemStyle: { - borderRadius: 4, - borderColor: '#0d1117', - borderWidth: 2, - }, - label: { show: false, position: 'center' }, - emphasis: { label: { show: false }, scale: true, scaleSize: 3 }, - labelLine: { show: false }, - data: [ - { - value: merged, - name: 'Merged', - itemStyle: { - color: CHART_COLORS.merged, - opacity: isPrimary ? 1 : 0.7, - }, - }, - { - value: open, - name: 'Open', - itemStyle: { - color: CHART_COLORS.open, - opacity: isPrimary ? 1 : 0.7, - }, - }, - { - value: closed, - name: 'Closed', - itemStyle: { - color: CHART_COLORS.closed, - opacity: isPrimary ? 1 : 0.7, - }, - }, - ], - }, - ], - }), - [merged, open, closed, credibilityPercent, isPrimary], - ); - - return ( - - - {title}{' '} - {subtitle && ( - - ({subtitle}) - - )} - - - - - - - - - - - - - ); -}; - -const StatItem: React.FC<{ label: string; value: number }> = ({ - label, - value, -}) => ( - - - {label} - - - {value} - - -); - -export default PRStatusChart; diff --git a/src/components/dashboard/RepositoriesTable.tsx b/src/components/dashboard/RepositoriesTable.tsx deleted file mode 100644 index b90e5b7f..00000000 --- a/src/components/dashboard/RepositoriesTable.tsx +++ /dev/null @@ -1,697 +0,0 @@ -import React, { useState, useMemo, useRef, useEffect } from 'react'; -import { - Card, - CardContent, - Typography, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TableSortLabel, - TablePagination, - Paper, - Stack, - useMediaQuery, - Tooltip, - CircularProgress, - Box, - TextField, - InputAdornment, - Avatar, -} from '@mui/material'; -import { useNavigate } from 'react-router-dom'; -import SearchIcon from '@mui/icons-material/Search'; -import theme from '../../theme'; -import { useRepoChanges } from '../../api'; -import dayjs from 'dayjs'; - -type SortField = - | 'repositoryFullName' - | 'commits' - | 'additions' - | 'deletions' - | 'linesChanged' - | 'weight'; -type SortOrder = 'asc' | 'desc'; - -const RepositoriesTable: React.FC = () => { - const navigate = useNavigate(); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - const isMedium = useMediaQuery(theme.breakpoints.down('md')); - const isLarge = useMediaQuery(theme.breakpoints.down('lg')); - const { data: repoChanges, isLoading } = useRepoChanges(); - - const [sortField, setSortField] = useState('weight'); - const [sortOrder, setSortOrder] = useState('desc'); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(8); - const [searchQuery, setSearchQuery] = useState(''); - const containerRef = useRef(null); - - // Dynamically calculate rows per page based on available height - useEffect(() => { - const calculateRows = () => { - if (containerRef.current) { - const containerHeight = containerRef.current.clientHeight; - const titleHeight = 40; // Title height - const headerHeight = 56; // Table header height - const paginationHeight = 52; // Pagination height - const rowHeight = 53; // Each table row height - const padding = 32; // Top and bottom padding - - const availableHeight = - containerHeight - - titleHeight - - headerHeight - - paginationHeight - - padding; - const calculatedRows = Math.floor(availableHeight / rowHeight); - const finalRows = Math.max(5, Math.min(calculatedRows, 20)); // Between 5 and 20 rows - - setRowsPerPage(finalRows); - } - }; - - calculateRows(); - window.addEventListener('resize', calculateRows); - return () => window.removeEventListener('resize', calculateRows); - }, []); - - const handleSort = (field: SortField) => { - if (sortField === field) { - setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); - } else { - setSortField(field); - setSortOrder( - field === 'weight' || - field === 'linesChanged' || - field === 'commits' || - field === 'additions' || - field === 'deletions' - ? 'desc' - : 'asc', - ); - } - setPage(0); - }; - - // Reset page when search query changes - useEffect(() => { - setPage(0); - }, [searchQuery]); - - const handleChangePage = (_event: unknown, newPage: number) => { - setPage(newPage); - }; - - const filteredAndSortedRepos = useMemo(() => { - if (!repoChanges) return []; - - // Filter by search query - let filtered = repoChanges; - if (searchQuery) { - const lowerQuery = searchQuery.toLowerCase(); - filtered = repoChanges.filter((repo) => - repo.repositoryFullName.toLowerCase().includes(lowerQuery), - ); - } - - // Create a copy before sorting to avoid mutation - const sorted = [...filtered]; - - sorted.sort((a, b) => { - let aValue: any; - let bValue: any; - - // Get the values based on sort field - switch (sortField) { - case 'repositoryFullName': - aValue = a.repositoryFullName; - bValue = b.repositoryFullName; - break; - case 'commits': - aValue = a.commits; - bValue = b.commits; - break; - case 'additions': - aValue = a.additions; - bValue = b.additions; - break; - case 'deletions': - aValue = a.deletions; - bValue = b.deletions; - break; - case 'linesChanged': - aValue = a.linesChanged; - bValue = b.linesChanged; - break; - case 'weight': - aValue = a.weight; - bValue = b.weight; - break; - default: - aValue = a.weight; - bValue = b.weight; - } - - // Handle null/undefined values - push them to the end - if (aValue == null && bValue == null) return 0; - if (aValue == null) return 1; - if (bValue == null) return -1; - - // For repository name, do string comparison - if (sortField === 'repositoryFullName') { - return sortOrder === 'asc' - ? aValue.localeCompare(bValue) - : bValue.localeCompare(aValue); - } - - // For all numeric fields (including weight which is a string), convert to number - const aNum = - typeof aValue === 'string' ? parseFloat(aValue) : Number(aValue); - const bNum = - typeof bValue === 'string' ? parseFloat(bValue) : Number(bValue); - - // Handle NaN values - if (isNaN(aNum) && isNaN(bNum)) return 0; - if (isNaN(aNum)) return 1; - if (isNaN(bNum)) return -1; - - return sortOrder === 'asc' ? aNum - bNum : bNum - aNum; - }); - - return sorted; - }, [repoChanges, sortField, sortOrder, searchQuery]); - - const paginatedRepos = useMemo(() => { - const startIndex = page * rowsPerPage; - const endIndex = startIndex + rowsPerPage; - return filteredAndSortedRepos.slice(startIndex, endIndex); - }, [filteredAndSortedRepos, page, rowsPerPage]); - - return ( - - - - - Contributed Repositories - - setSearchQuery(e.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} - sx={{ - width: isMobile ? '100%' : '200px', - '& .MuiOutlinedInput-root': { - color: '#ffffff', - fontFamily: '"JetBrains Mono", monospace', - backgroundColor: 'rgba(0, 0, 0, 0.4)', - fontSize: '0.8rem', - height: '36px', - borderRadius: 2, - '& fieldset': { borderColor: 'rgba(255, 255, 255, 0.1)' }, - '&:hover fieldset': { borderColor: 'rgba(255, 255, 255, 0.2)' }, - '&.Mui-focused fieldset': { borderColor: 'primary.main' }, - }, - }} - /> - - - {isLoading ? ( - - - - ) : ( - - - - - - handleSort('repositoryFullName')} - sx={{ - '&:hover': { - color: 'secondary.main', - }, - '&.Mui-active': { - color: 'secondary.main', - }, - }} - > - - Repository - - - - {!isMobile && ( - - handleSort('commits')} - sx={{ - '&:hover': { - color: 'secondary.main', - }, - '&.Mui-active': { - color: 'secondary.main', - }, - }} - > - - Commits - - - - )} - {!isMobile && !isLarge && ( - - handleSort('additions')} - sx={{ - '&:hover': { - color: 'secondary.main', - }, - '&.Mui-active': { - color: 'secondary.main', - }, - }} - > - - Lines Added - - - - )} - {!isMobile && !isLarge && ( - - handleSort('deletions')} - sx={{ - '&:hover': { - color: 'secondary.main', - }, - '&.Mui-active': { - color: 'secondary.main', - }, - }} - > - - Lines Removed - - - - )} - - handleSort('linesChanged')} - sx={{ - '&:hover': { - color: 'secondary.main', - }, - '&.Mui-active': { - color: 'secondary.main', - }, - }} - > - - Lines Changed - - - - {!isMobile && !isMedium && ( - - handleSort('weight')} - sx={{ - '&:hover': { - color: 'secondary.main', - }, - '&.Mui-active': { - color: 'secondary.main', - }, - }} - > - - Weight - - - - )} - - - - {paginatedRepos.map((repo) => { - const isInactive = - repo.inactiveAt !== null && repo.inactiveAt !== undefined; - const inactiveDate = isInactive - ? dayjs(repo.inactiveAt).format('DD/MM/YY hh:mm a') - : null; - - return ( - - - - - - - navigate( - `/miners/repository?name=${repo.repositoryFullName}`, - { state: { backLabel: 'Back to Dashboard' } }, - ) - } - sx={{ - color: isInactive - ? 'error.dark' - : 'text.primary', - cursor: 'pointer', - '&:hover': { - textDecoration: 'underline', - color: 'primary.main', - }, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - display: 'block', - }} - > - {repo.repositoryFullName} - - - - {!isMobile && ( - - - {repo.commits} - - - )} - {!isMobile && !isLarge && ( - - - +{repo.additions ?? '-'} - - - )} - {!isMobile && !isLarge && ( - - - -{repo.deletions ?? '-'} - - - )} - - - {repo.linesChanged ?? '-'} - - - {!isMobile && !isMedium && ( - - - {repo.weight ?? '-'} - - - )} - - - ); - })} - -
-
- )} - - - - {filteredAndSortedRepos.length === 0 && !isLoading && ( - - No repositories found! - - )} -
-
- ); -}; - -export default RepositoriesTable; diff --git a/src/components/dashboard/index.ts b/src/components/dashboard/index.ts deleted file mode 100644 index c39361bc..00000000 --- a/src/components/dashboard/index.ts +++ /dev/null @@ -1,23 +0,0 @@ -export { default as CommitTrendChart } from './CommitTrendChart'; -export * from './CommitTrendChart'; - -export { default as ContributionHeatmap } from './ContributionHeatmap'; -export * from './ContributionHeatmap'; - -export { default as GlobalActivity } from './GlobalActivity'; -export * from './GlobalActivity'; - -export { default as KpiCard } from './KpiCard'; -export * from './KpiCard'; - -export { default as LeaderboardCharts } from './LeaderboardCharts'; -export * from './LeaderboardCharts'; - -export { default as LiveCommitLog } from './LiveCommitLog'; -export * from './LiveCommitLog'; - -export { default as PRStatusChart } from './PRStatusChart'; -export * from './PRStatusChart'; - -export { default as RepositoriesTable } from './RepositoriesTable'; -export * from './RepositoriesTable'; diff --git a/src/components/index.ts b/src/components/index.ts index 5b89a9d4..ab3fb50c 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -1,10 +1,15 @@ -export * from './dashboard'; export * from './layout'; export * from './miners'; export * from './repositories'; export * from './prs'; export * from './leaderboard'; export * from './common'; +export { default as ContributionHeatmap } from './ContributionHeatmap'; +export { default as KpiCard } from './KpiCard'; +export * from './KpiCard'; export { default as FAQ } from './FAQ'; export { default as BackButton } from './BackButton'; export { SEO } from './SEO'; +export { default as FilterButton } from './FilterButton'; +export { default as ErrorBoundary } from './ErrorBoundary'; +export { default as ErrorFallback } from './ErrorFallback'; diff --git a/src/components/issues/BountyProgress.tsx b/src/components/issues/BountyProgress.tsx index 2041f46d..95933a95 100644 --- a/src/components/issues/BountyProgress.tsx +++ b/src/components/issues/BountyProgress.tsx @@ -1,7 +1,13 @@ import React from 'react'; -import { Box, LinearProgress, Typography } from '@mui/material'; +import { + Box, + LinearProgress, + Typography, + alpha, + useTheme, +} from '@mui/material'; import { formatTokenAmount } from '../../utils/format'; -import { STATUS_COLORS } from '../../theme'; +import { STATUS_COLORS, TEXT_OPACITY } from '../../theme'; interface BountyProgressProps { bountyAmount: string; @@ -12,6 +18,7 @@ const BountyProgress: React.FC = ({ bountyAmount, targetBounty, }) => { + const theme = useTheme(); const current = parseFloat(bountyAmount) || 0; const target = parseFloat(targetBounty) || 0; const percentage = target > 0 ? Math.min((current / target) * 100, 100) : 0; @@ -25,7 +32,7 @@ const BountyProgress: React.FC = ({ sx={{ height: 6, borderRadius: 3, - backgroundColor: 'rgba(255, 255, 255, 0.1)', + backgroundColor: 'surface.light', '& .MuiLinearProgress-bar': { borderRadius: 3, backgroundColor: isFunded @@ -36,9 +43,8 @@ const BountyProgress: React.FC = ({ /> = ({ issue }) => { sx={{ width: 40, height: 40, - border: '1px solid rgba(255,255,255,0.1)', + border: `1px solid ${theme.palette.border.light}`, backgroundColor: theme.palette.background.paper, // Avoid transparency issues over the line }} /> @@ -195,11 +204,7 @@ const IssueConversation: React.FC = ({ issue }) => { component="span" sx={{ fontSize: 'inherit', color: 'inherit' }} > - {new Date(item.createdAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} + {formatDate(item.createdAt)}
@@ -226,7 +231,7 @@ const IssueConversation: React.FC = ({ issue }) => { label="Description" sx={{ color: STATUS_COLORS.info, - borderColor: 'rgba(56, 139, 253, 0.4)', + borderColor: alpha(STATUS_COLORS.info, 0.4), }} /> )} @@ -243,6 +248,7 @@ const IssueConversation: React.FC = ({ issue }) => { fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"', // GitHub's exact font stack overflowX: 'auto', + ...scrollbarSx, // Typography refinements '& > *:first-of-type': { mt: 0 }, '& > *:last-child': { mb: 0 }, @@ -284,9 +290,8 @@ const IssueConversation: React.FC = ({ issue }) => { padding: '0.2em 0.4em', margin: 0, fontSize: '85%', - backgroundColor: 'rgba(110, 118, 129, 0.4)', + backgroundColor: alpha(STATUS_COLORS.neutral, 0.4), borderRadius: '6px', - fontFamily: '"JetBrains Mono", monospace', }, '& pre': { mt: 2, diff --git a/src/components/issues/IssueHeaderCard.tsx b/src/components/issues/IssueHeaderCard.tsx index 1a7484bf..166f6085 100644 --- a/src/components/issues/IssueHeaderCard.tsx +++ b/src/components/issues/IssueHeaderCard.tsx @@ -1,81 +1,42 @@ import React from 'react'; -import { Box, Card, Typography, Chip, Link, Stack } from '@mui/material'; +import { + Box, + Card, + Typography, + Chip, + Link, + Stack, + alpha, + useTheme, +} from '@mui/material'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import { IssueDetails } from '../../api/models/Issues'; -import { useStats } from '../../api'; -import { formatTokenAmount } from '../../utils/format'; -import { STATUS_COLORS } from '../../theme'; - -const getStatusBadge = ( - status: string, -): { color: string; bgColor: string; text: string } => { - switch (status) { - case 'registered': - return { - color: STATUS_COLORS.warning, - bgColor: 'rgba(245, 158, 11, 0.15)', - text: 'Pending', - }; - case 'active': - return { - color: STATUS_COLORS.info, - bgColor: 'rgba(88, 166, 255, 0.15)', - text: 'Available', - }; - case 'completed': - return { - color: STATUS_COLORS.merged, - bgColor: 'rgba(63, 185, 80, 0.15)', - text: 'Completed', - }; - case 'cancelled': - return { - color: STATUS_COLORS.error, - bgColor: 'rgba(239, 68, 68, 0.15)', - text: 'Cancelled', - }; - default: - return { - color: STATUS_COLORS.open, - bgColor: 'rgba(139, 148, 158, 0.15)', - text: status, - }; - } -}; - -const formatDate = (dateStr: string | null | undefined): string => { - if (!dateStr) return '-'; - const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); -}; +import { + formatTokenAmount, + formatDate, + formatAlphaToUsd, +} from '../../utils/format'; +import { usePrices } from '../../hooks/usePrices'; +import { getIssueStatusMeta } from '../../utils/issueStatus'; +import { STATUS_COLORS, TEXT_OPACITY } from '../../theme'; interface IssueHeaderCardProps { issue: IssueDetails; } const IssueHeaderCard: React.FC = ({ issue }) => { - const statusBadge = getStatusBadge(issue.status); - const { data: dashStats } = useStats(); - const taoPrice = dashStats?.prices?.tao?.data?.price ?? 0; - const alphaPrice = dashStats?.prices?.alpha?.data?.price ?? 0; - - const usdEstimate = React.useMemo(() => { - if (taoPrice <= 0 || alphaPrice <= 0) return null; - const amount = parseFloat(issue.targetBounty); - if (isNaN(amount) || amount === 0) return null; - const usd = amount * alphaPrice * taoPrice; - return `~${usd.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}`; - }, [issue.targetBounty, taoPrice, alphaPrice]); + const theme = useTheme(); + const statusBadge = getIssueStatusMeta(issue.status); + const { taoPrice, alphaPrice, hasPrices } = usePrices(); + const usdEstimate = hasPrices + ? formatAlphaToUsd(issue.targetBounty, taoPrice, alphaPrice) + : null; return ( = ({ issue }) => { display: 'flex', alignItems: 'center', gap: 0.5, - fontFamily: '"JetBrains Mono", monospace', fontSize: '1rem', color: STATUS_COLORS.info, textDecoration: 'none', @@ -115,7 +75,6 @@ const IssueHeaderCard: React.FC = ({ issue }) => { label={statusBadge.text} size="small" sx={{ - fontFamily: '"JetBrains Mono", monospace', fontSize: '0.75rem', fontWeight: 600, backgroundColor: statusBadge.bgColor, @@ -133,7 +92,7 @@ const IssueHeaderCard: React.FC = ({ issue }) => { '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif', fontSize: '1.5rem', fontWeight: 600, - color: '#ffffff', + color: 'text.primary', }} > {issue.title} @@ -152,9 +111,8 @@ const IssueHeaderCard: React.FC = ({ issue }) => { = ({ issue }) => { > = ({ issue }) => { / {formatTokenAmount(issue.targetBounty)} ل @@ -193,7 +152,6 @@ const IssueHeaderCard: React.FC = ({ issue }) => { ) : issue.status === 'completed' ? ( = ({ issue }) => { ) : ( {formatTokenAmount(issue.targetBounty)} ل @@ -219,9 +176,8 @@ const IssueHeaderCard: React.FC = ({ issue }) => { {usdEstimate && ( @@ -234,9 +190,11 @@ const IssueHeaderCard: React.FC = ({ issue }) => { = ({ issue }) => { {issue.authorLogin} @@ -259,9 +216,8 @@ const IssueHeaderCard: React.FC = ({ issue }) => { = ({ issue }) => { {formatDate(issue.createdAt)} @@ -290,10 +245,9 @@ const IssueHeaderCard: React.FC = ({ issue }) => { label={label} size="small" sx={{ - fontFamily: '"JetBrains Mono", monospace', fontSize: '0.7rem', - backgroundColor: 'rgba(255, 255, 255, 0.1)', - color: '#ffffff', + backgroundColor: alpha(theme.palette.common.white, 0.1), + color: 'text.primary', }} /> ))} diff --git a/src/components/issues/IssueStats.tsx b/src/components/issues/IssueStats.tsx index bb3f883b..9591d1a3 100644 --- a/src/components/issues/IssueStats.tsx +++ b/src/components/issues/IssueStats.tsx @@ -1,23 +1,11 @@ import React from 'react'; -import { Grid, Skeleton, Box } from '@mui/material'; +import { Skeleton, Box } from '@mui/material'; import { IssuesStats } from '../../api/models/Issues'; -import { useStats } from '../../api'; -import KpiCard from '../dashboard/KpiCard'; -import { formatTokenAmount } from '../../utils/format'; +import KpiCard from '../KpiCard'; +import { formatTokenAmount, formatAlphaToUsd } from '../../utils/format'; +import { usePrices } from '../../hooks/usePrices'; import { STATUS_COLORS } from '../../theme'; -const formatUsd = ( - alphaAmount: string | undefined, - taoPrice: number, - alphaPrice: number, -): string | undefined => { - if (!alphaAmount) return undefined; - const amount = parseFloat(alphaAmount); - if (isNaN(amount) || amount === 0) return undefined; - const usd = amount * alphaPrice * taoPrice; - return `~${usd.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}`; -}; - interface IssueStatsProps { stats?: IssuesStats; isLoading?: boolean; @@ -27,79 +15,87 @@ const IssueStats: React.FC = ({ stats, isLoading = false, }) => { - const { data: dashStats } = useStats(); - const taoPrice = dashStats?.prices?.tao?.data?.price ?? 0; - const alphaPrice = dashStats?.prices?.alpha?.data?.price ?? 0; - const hasPrices = taoPrice > 0 && alphaPrice > 0; + const { taoPrice, alphaPrice, hasPrices } = usePrices(); + const cards = [ + { + title: 'Total Issues', + value: stats?.totalIssues ?? 0, + subtitle: 'All registered issues', + }, + { + title: 'Available Issues', + value: stats?.activeIssues ?? 0, + subtitle: 'Available for solving', + }, + { + title: 'Bounty Pool', + value: `${formatTokenAmount(stats?.totalBountyPool)} ل`, + subtitle: hasPrices + ? (formatAlphaToUsd(stats?.totalBountyPool, taoPrice, alphaPrice) ?? + 'Total available') + : 'Total available', + }, + { + title: 'Total Payouts', + value: `${formatTokenAmount(stats?.totalPayouts)} ل`, + subtitle: hasPrices + ? (formatAlphaToUsd(stats?.totalPayouts, taoPrice, alphaPrice) ?? + 'Paid to solvers') + : 'Paid to solvers', + sx: { + '& .MuiTypography-h4': { + color: STATUS_COLORS.merged, + }, + }, + }, + ]; + + const gridSx = { + display: 'grid', + gridTemplateColumns: { + xs: 'repeat(2, minmax(0, 1fr))', + sm: 'repeat(4, minmax(0, 1fr))', + }, + gap: 2, + width: '100%', + } as const; if (isLoading) { return ( - + {[1, 2, 3, 4].map((i) => ( - + - + ))} - + ); } return ( - - - - - - - - - - - - - - + + {cards.map((card) => ( + + + + ))} + ); }; diff --git a/src/components/issues/IssueSubmissionsTable.tsx b/src/components/issues/IssueSubmissionsTable.tsx index ff92b1ef..c9c9fd00 100644 --- a/src/components/issues/IssueSubmissionsTable.tsx +++ b/src/components/issues/IssueSubmissionsTable.tsx @@ -1,51 +1,22 @@ import React from 'react'; -import { useNavigate } from 'react-router-dom'; import { Box, Card, - Typography, Chip, CircularProgress, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, + Typography, + alpha, useTheme, } from '@mui/material'; import EmojiEventsIcon from '@mui/icons-material/EmojiEvents'; import { IssueSubmission } from '../../api/models/Issues'; -import { STATUS_COLORS } from '../../theme'; - -const formatDate = (dateStr: string | null | undefined): string => { - if (!dateStr) return '-'; - const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); -}; - -const headerCellSx = { - fontFamily: '"JetBrains Mono", monospace', - fontSize: '0.7rem', - fontWeight: 600, - letterSpacing: '0.5px', - textTransform: 'uppercase' as const, - color: 'rgba(255, 255, 255, 0.3)', - borderBottom: '1px solid rgba(255, 255, 255, 0.1)', - py: 1.5, -}; - -const bodyCellSx = { - fontFamily: '"JetBrains Mono", monospace', - fontSize: '0.85rem', - color: '#ffffff', - borderBottom: '1px solid rgba(255, 255, 255, 0.05)', - py: 1.5, -}; +import { STATUS_COLORS, TEXT_OPACITY } from '../../theme'; +import { formatDate } from '../../utils/format'; +import { LinkBox } from '../common/linkBehavior'; +import { + DataTable, + type DataTableColumn, +} from '../../components/common/DataTable'; interface IssueSubmissionsTableProps { submissions: IssueSubmission[] | undefined; @@ -58,26 +29,134 @@ const IssueSubmissionsTable: React.FC = ({ isLoading, backLabel, }) => { - const navigate = useNavigate(); const theme = useTheme(); + const linkState = backLabel ? { backLabel } : undefined; + + const columns: DataTableColumn[] = [ + { + key: 'pr', + header: 'PR', + renderCell: (submission) => ( + + #{submission.number} + {submission.isWinner && ( + + )} + + ), + }, + { + key: 'title', + header: 'Title', + cellSx: { + maxWidth: 300, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + renderCell: (submission) => submission.title, + }, + { + key: 'author', + header: 'Author', + renderCell: (submission) => + submission.authorGithubId ? ( + e.stopPropagation()} + sx={{ + fontSize: '0.85rem', + color: STATUS_COLORS.info, + cursor: 'pointer', + '&:hover': { textDecoration: 'underline' }, + }} + > + {submission.authorLogin} + + ) : ( + + {submission.authorLogin} + + ), + }, + { + key: 'status', + header: 'Status', + align: 'center', + renderCell: (submission) => { + const state = submission.mergedAt + ? 'MERGED' + : submission.prState === 'OPEN' + ? 'OPEN' + : 'CLOSED'; + const color = + state === 'MERGED' + ? theme.palette.status.merged + : state === 'OPEN' + ? theme.palette.status.open + : theme.palette.status.closed; + return ( + + ); + }, + }, + { + key: 'tokens', + header: 'Tokens', + align: 'right', + renderCell: (submission) => ( + + {Number(submission.tokenScore).toLocaleString()} + + ), + }, + { + key: 'date', + header: 'Date', + align: 'center', + renderCell: (submission) => ( + + {formatDate(submission.prCreatedAt)} + + ), + }, + ]; return ( = ({ Submissions ({submissions?.length || 0}) - {isLoading ? ( + // Preserve original loading UI: smaller spinner, tighter padding. - ) : !submissions || submissions.length === 0 ? ( - - - No submissions yet - - ) : ( - - - - - PR - Title - Author - - Status - - - Tokens - - - Date - - - - - {submissions.map((submission) => ( - - navigate( - `/miners/pr?repo=${encodeURIComponent(submission.repositoryFullName)}&number=${submission.number}`, - backLabel ? { state: { backLabel } } : undefined, - ) - } - sx={{ - cursor: 'pointer', - transition: 'background-color 0.2s', - '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.03)', - }, - }} - > - - - #{submission.number} - {submission.isWinner && ( - - )} - - - - {submission.title} - - - {submission.authorGithubId ? ( - { - e.stopPropagation(); - navigate( - `/miners/details?githubId=${submission.authorGithubId}`, - backLabel ? { state: { backLabel } } : undefined, - ); - }} - sx={{ - fontFamily: '"JetBrains Mono", monospace', - fontSize: '0.85rem', - color: STATUS_COLORS.info, - cursor: 'pointer', - '&:hover': { - textDecoration: 'underline', - }, - }} - > - {submission.authorLogin} - - ) : ( - - {submission.authorLogin} - - )} - - - {(() => { - const state = submission.mergedAt - ? 'MERGED' - : submission.prState === 'OPEN' - ? 'OPEN' - : 'CLOSED'; - let color = theme.palette.status.neutral; - if (state === 'MERGED') { - color = theme.palette.status.merged; - } else if (state === 'OPEN') { - color = theme.palette.status.open; - } else if (state === 'CLOSED') { - color = theme.palette.status.closed; - } - return ( - - ); - })()} - - - - {Number(submission.tokenScore).toLocaleString()} - - - - - {formatDate(submission.prCreatedAt)} - - - - ))} - -
-
+ + `${submission.repositoryFullName}-${submission.number}` + } + emptyState={ + + + No submissions yet + + + } + getRowHref={(submission) => + `/miners/pr?repo=${encodeURIComponent(submission.repositoryFullName)}&number=${submission.number}` + } + linkState={linkState} + /> )}
); diff --git a/src/components/issues/IssuesList.tsx b/src/components/issues/IssuesList.tsx index fcc731be..d79ff3cd 100644 --- a/src/components/issues/IssuesList.tsx +++ b/src/components/issues/IssuesList.tsx @@ -1,92 +1,65 @@ -import React from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { + Avatar, Box, Card, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Typography, Chip, - Skeleton, + Collapse, + FormControl, + IconButton, + InputAdornment, Link, + MenuItem, + Select, + Skeleton, + Stack, + TablePagination, + TextField, Tooltip, - Avatar, + Typography, + alpha, + useTheme, } from '@mui/material'; +import BarChartIcon from '@mui/icons-material/BarChart'; import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import SearchIcon from '@mui/icons-material/Search'; +import TableChartIcon from '@mui/icons-material/TableChart'; +import ReactECharts from 'echarts-for-react'; import { IssueBounty } from '../../api/models/Issues'; -import { useStats } from '../../api'; -import { formatTokenAmount } from '../../utils/format'; -import { STATUS_COLORS } from '../../theme'; +import { usePrices } from '../../hooks/usePrices'; +import { + formatTokenAmount, + formatDate, + formatAlphaToUsd, +} from '../../utils/format'; +import { getIssueStatusMeta } from '../../utils/issueStatus'; +import { STATUS_COLORS, TEXT_OPACITY } from '../../theme'; +import { DataTable, type DataTableColumn } from '../common/DataTable'; import BountyProgress from './BountyProgress'; +import FilterButton from '../FilterButton'; + +type FilterType = 'all' | 'available' | 'pending' | 'history'; +type SortDirection = 'asc' | 'desc'; +type SortKey = + | 'id' + | 'repository' + | 'issue' + | 'bounty' + | 'status' + | 'funding' + | 'solver' + | 'date'; -type ListType = 'available' | 'pending' | 'history'; +const VALID_ROWS = [10, 25, 50]; interface IssuesListProps { issues: IssueBounty[]; isLoading?: boolean; - listType: ListType; - onSelectIssue?: (id: number) => void; + getIssueHref?: (id: number) => string; + linkState?: Record; } -/** - * Get status badge color and text - */ -const getStatusBadge = ( - status: IssueBounty['status'], -): { color: string; bgColor: string; text: string } => { - switch (status) { - case 'registered': - return { - color: STATUS_COLORS.warning, - bgColor: 'rgba(245, 158, 11, 0.15)', - text: 'Pending', - }; - case 'active': - return { - color: STATUS_COLORS.info, - bgColor: 'rgba(88, 166, 255, 0.15)', - text: 'Available', - }; - case 'completed': - return { - color: STATUS_COLORS.merged, - bgColor: 'rgba(63, 185, 80, 0.15)', - text: 'Completed', - }; - case 'cancelled': - return { - color: STATUS_COLORS.error, - bgColor: 'rgba(239, 68, 68, 0.15)', - text: 'Cancelled', - }; - default: - return { - color: STATUS_COLORS.open, - bgColor: 'rgba(139, 148, 158, 0.15)', - text: status, - }; - } -}; - -/** - * Format date for display - */ -const formatDate = (dateStr: string | null | undefined): string => { - if (!dateStr) return '-'; - const date = new Date(dateStr); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); -}; - -/** - * Truncate wallet address for display - */ const truncateAddress = (address: string | null): string => { if (!address) return '-'; if (address.length <= 12) return address; @@ -96,45 +69,541 @@ const truncateAddress = (address: string | null): string => { const IssuesList: React.FC = ({ issues, isLoading = false, - listType, - onSelectIssue, + getIssueHref, + linkState, }) => { - const { data: dashStats } = useStats(); - const taoPrice = dashStats?.prices?.tao?.data?.price ?? 0; - const alphaPrice = dashStats?.prices?.alpha?.data?.price ?? 0; - - const toUsd = (alphaAmount: string): string | null => { - if (taoPrice <= 0 || alphaPrice <= 0) return null; - const amount = parseFloat(alphaAmount); - if (isNaN(amount) || amount === 0) return null; - const usd = amount * alphaPrice * taoPrice; - return `~${usd.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 })}`; - }; - const headerCellSx = { - fontFamily: '"JetBrains Mono", monospace', - fontSize: '0.7rem', - fontWeight: 600, - letterSpacing: '0.5px', - textTransform: 'uppercase' as const, - color: 'rgba(255, 255, 255, 0.3)', - borderBottom: '1px solid rgba(255, 255, 255, 0.1)', - py: 1.5, - }; + const theme = useTheme(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Derive filterType directly from URL — single source of truth so that + // redirects from /bounties/:tab and browser back/forward both work correctly. + const filterType = useMemo(() => { + const f = searchParams.get('filter'); + if (f === 'available' || f === 'pending' || f === 'history') return f; + return 'all'; + }, [searchParams]); + + const [sortKey, setSortKey] = useState('id'); + const [sortDirection, setSortDirection] = useState('desc'); + const [rowsPerPage, setRowsPerPage] = useState(10); + const [page, setPage] = useState(0); + const [searchQuery, setSearchQuery] = useState(''); + const [showChart, setShowChart] = useState(false); + + const { taoPrice, alphaPrice } = usePrices(); + + const handleFilterChange = useCallback( + (f: FilterType) => { + if (f === 'all') { + setSearchParams({}, { replace: true }); + } else { + setSearchParams({ filter: f }, { replace: true }); + } + }, + [setSearchParams], + ); + + const counts = useMemo( + () => ({ + all: issues.length, + available: issues.filter((i) => i.status === 'active').length, + pending: issues.filter((i) => i.status === 'registered').length, + history: issues.filter( + (i) => i.status === 'completed' || i.status === 'cancelled', + ).length, + }), + [issues], + ); - const bodyCellSx = { - fontFamily: '"JetBrains Mono", monospace', - fontSize: '0.85rem', - color: 'text.primary', - borderBottom: '1px solid rgba(255, 255, 255, 0.05)', - py: 1.5, + const filteredByType = useMemo(() => { + if (filterType === 'available') + return issues.filter((i) => i.status === 'active'); + if (filterType === 'pending') + return issues.filter((i) => i.status === 'registered'); + if (filterType === 'history') + return issues.filter( + (i) => i.status === 'completed' || i.status === 'cancelled', + ); + return issues; + }, [issues, filterType]); + + const filteredIssues = useMemo(() => { + if (!searchQuery) return filteredByType; + const q = searchQuery.toLowerCase(); + return filteredByType.filter( + (i) => + i.repositoryFullName.toLowerCase().includes(q) || + i.title?.toLowerCase().includes(q) || + String(i.issueNumber).includes(q), + ); + }, [filteredByType, searchQuery]); + + const parseAmount = (value: string | null | undefined): number => { + const parsed = Number.parseFloat(value ?? '0'); + return Number.isFinite(parsed) ? parsed : 0; }; + const getLowerText = (value: string | null | undefined): string => + (value ?? '').toLowerCase(); + + const getDefaultSortDirection = useCallback( + (key: SortKey): SortDirection => + key === 'id' || key === 'bounty' || key === 'date' ? 'desc' : 'asc', + [], + ); + + const visibleSortKeys = useMemo(() => { + const common: SortKey[] = ['id', 'repository', 'issue']; + if (filterType === 'pending') + return [...common, 'bounty', 'funding', 'status']; + if (filterType === 'history') + return [...common, 'bounty', 'solver', 'status', 'date']; + return [...common, 'bounty', 'status']; + }, [filterType]); + + useEffect(() => { + if (!visibleSortKeys.includes(sortKey)) { + setSortKey('id'); + setSortDirection('desc'); + } + }, [sortKey, visibleSortKeys]); + + useEffect(() => { + setPage(0); + }, [filterType, searchQuery]); + + const handleSort = useCallback( + (key: SortKey) => { + if (!visibleSortKeys.includes(key)) return; + if (sortKey === key) { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + return; + } + setSortKey(key); + setSortDirection(getDefaultSortDirection(key)); + }, + [getDefaultSortDirection, sortKey, visibleSortKeys], + ); + + const sortedIssues = useMemo(() => { + const directionFactor = sortDirection === 'asc' ? 1 : -1; + const collator = new Intl.Collator(undefined, { + sensitivity: 'base', + numeric: true, + }); + + const decorated = filteredIssues.map((issue) => { + let value: number | string; + switch (sortKey) { + case 'id': + value = issue.id; + break; + case 'repository': + value = getLowerText(issue.repositoryFullName); + break; + case 'issue': + value = `${getLowerText(issue.title)}::${String(issue.issueNumber).padStart(10, '0')}`; + break; + case 'bounty': + value = parseAmount(issue.targetBounty); + break; + case 'status': + value = getIssueStatusMeta(issue.status).text; + break; + case 'funding': { + const target = parseAmount(issue.targetBounty); + value = target > 0 ? parseAmount(issue.bountyAmount) / target : 0; + break; + } + case 'solver': + value = getLowerText(issue.solverHotkey); + break; + case 'date': + value = new Date(issue.completedAt || issue.updatedAt || 0).getTime(); + break; + default: + value = issue.id; + } + return { issue, value }; + }); + + decorated.sort((a, b) => { + if (typeof a.value === 'number' && typeof b.value === 'number') { + return (a.value - b.value) * directionFactor; + } + return ( + collator.compare(String(a.value), String(b.value)) * directionFactor + ); + }); + + return decorated.map((item) => item.issue); + }, [filteredIssues, sortDirection, sortKey]); + + const paginatedIssues = useMemo(() => { + const start = page * rowsPerPage; + return sortedIssues.slice(start, start + rowsPerPage); + }, [sortedIssues, page, rowsPerPage]); + + const chartOption = useMemo(() => { + const repoTotals = new Map(); + filteredIssues.forEach((issue) => { + const amount = parseAmount(issue.targetBounty); + repoTotals.set( + issue.repositoryFullName, + (repoTotals.get(issue.repositoryFullName) || 0) + amount, + ); + }); + const sorted = [...repoTotals.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 20); + const textColor = alpha(theme.palette.common.white, 0.85); + const gridColor = theme.palette.border.subtle; + + return { + backgroundColor: 'transparent', + title: { + text: 'Bounty Pool by Repository', + subtext: `${filteredIssues.length} issues`, + left: 'center', + top: 20, + textStyle: { + color: theme.palette.text.primary, + fontFamily: 'JetBrains Mono', + fontSize: 16, + fontWeight: 600, + }, + subtextStyle: { + color: alpha(theme.palette.common.white, TEXT_OPACITY.tertiary), + fontFamily: 'JetBrains Mono', + fontSize: 12, + }, + }, + tooltip: { + trigger: 'axis', + axisPointer: { type: 'shadow' }, + backgroundColor: alpha(theme.palette.background.default, 0.95), + borderColor: alpha(theme.palette.common.white, 0.15), + borderWidth: 1, + textStyle: { + color: theme.palette.text.primary, + fontFamily: 'JetBrains Mono', + }, + formatter: (params: { name: string; value: number }[]) => { + const p = params[0]; + return `${p.name}: ${p.value.toFixed(4)} ل`; + }, + }, + grid: { + left: '3%', + right: '3%', + bottom: '15%', + top: '20%', + containLabel: true, + }, + xAxis: { + type: 'category', + data: sorted.map(([repo]) => repo.split('/')[1] || repo), + axisLabel: { + color: textColor, + fontFamily: 'JetBrains Mono', + rotate: 45, + interval: 0, + }, + axisLine: { lineStyle: { color: gridColor } }, + }, + yAxis: { + type: 'value', + name: 'Bounty (α)', + nameTextStyle: { color: textColor, fontFamily: 'JetBrains Mono' }, + axisLabel: { color: textColor, fontFamily: 'JetBrains Mono' }, + splitLine: { lineStyle: { color: gridColor, type: 'dashed' } }, + }, + series: [ + { + data: sorted.map(([, v]) => v), + type: 'bar', + itemStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: theme.palette.primary.main }, + { offset: 1, color: theme.palette.status.info }, + ], + }, + borderRadius: [4, 4, 0, 0], + }, + }, + ], + }; + }, [filteredIssues, theme]); + + const columns = useMemo[]>(() => { + const idColumn: DataTableColumn = { + key: 'id', + header: 'ID', + width: '60px', + sortKey: 'id', + renderCell: (issue) => ( + + #{issue.id} + + ), + }; + + const repositoryColumn: DataTableColumn = { + key: 'repository', + header: 'Repository', + width: '200px', + sortKey: 'repository', + cellSx: { overflow: 'hidden' }, + renderCell: (issue) => ( + + + + {issue.repositoryFullName} + + + ), + }; + + const issueColumn: DataTableColumn = { + key: 'issue', + header: 'Issue', + sortKey: 'issue', + renderCell: (issue) => ( + + {issue.title && ( + + {issue.title} + + )} + e.stopPropagation()} + sx={{ + display: 'flex', + alignItems: 'center', + gap: 0.5, + fontSize: '0.75rem', + color: alpha(theme.palette.common.white, TEXT_OPACITY.tertiary), + textDecoration: 'none', + '&:hover': { + color: STATUS_COLORS.info, + textDecoration: 'underline', + }, + }} + > + #{issue.issueNumber} + + + + ), + }; + + const bountyColumn = ( + label: string, + width?: string, + colorFn?: (issue: IssueBounty) => string, + ): DataTableColumn => ({ + key: 'bounty', + header: label, + width, + align: 'right', + sortKey: 'bounty', + renderCell: (issue) => { + const usdDisplay = formatAlphaToUsd( + issue.targetBounty, + taoPrice, + alphaPrice, + ); + const color = + colorFn?.(issue) ?? + (filterType === 'pending' + ? STATUS_COLORS.award + : STATUS_COLORS.merged); + return ( + <> + + {formatTokenAmount(issue.targetBounty)} ل + + {usdDisplay && ( + + {usdDisplay} + + )} + + ); + }, + }); + + const statusColumn = ( + width?: string, + ): DataTableColumn => ({ + key: 'status', + header: 'Status', + width, + align: 'center', + sortKey: 'status', + renderCell: (issue) => { + const statusBadge = getIssueStatusMeta(issue.status); + return ( + + ); + }, + }); + + const fundingColumn: DataTableColumn = { + key: 'funding', + header: 'Funding', + width: '140px', + align: 'center', + sortKey: 'funding', + renderCell: (issue) => ( + + ), + }; + + const solverColumn: DataTableColumn = { + key: 'solver', + header: 'Solver', + width: '160px', + align: 'center', + sortKey: 'solver', + renderCell: (issue) => + issue.solverHotkey ? ( + + + {truncateAddress(issue.solverHotkey)} + + + ) : ( + + - + + ), + }; + + const dateColumn: DataTableColumn = { + key: 'date', + header: 'Date', + width: '110px', + align: 'center', + sortKey: 'date', + renderCell: (issue) => ( + + {formatDate(issue.completedAt || issue.updatedAt)} + + ), + }; + + if (filterType === 'pending') { + return [ + idColumn, + repositoryColumn, + issueColumn, + bountyColumn('Target Bounty', '140px'), + fundingColumn, + statusColumn('110px'), + ]; + } + if (filterType === 'history') { + return [ + idColumn, + repositoryColumn, + issueColumn, + bountyColumn('Payout', '120px', (issue) => + issue.status === 'completed' + ? STATUS_COLORS.merged + : alpha(theme.palette.common.white, TEXT_OPACITY.muted), + ), + solverColumn, + statusColumn('110px'), + dateColumn, + ]; + } + // 'all' and 'available' share the same column set + return [ + idColumn, + repositoryColumn, + issueColumn, + bountyColumn('Bounty', '120px'), + statusColumn('110px'), + ]; + }, [filterType, theme, taoPrice, alphaPrice]); + if (isLoading) { return ( = ({ ); } - const emptyMessages: Record = { - available: 'No active issues available for solving', - pending: 'No pending issues awaiting funding', - history: 'No completed or cancelled issues yet', - }; - - if (issues.length === 0) { - return ( - + - - {emptyMessages[listType]} - - - ); - } + + handleFilterChange('all')} + count={counts.all} + color={theme.palette.status.neutral} + /> + handleFilterChange('available')} + count={counts.available} + color={theme.palette.status.merged} + /> + handleFilterChange('pending')} + count={counts.pending} + color={theme.palette.status.warning} + /> + handleFilterChange('history')} + count={counts.history} + color={theme.palette.status.neutral} + /> + + + + + setShowChart(!showChart)} + size="small" + sx={{ + color: showChart + ? theme.palette.text.primary + : alpha(theme.palette.common.white, TEXT_OPACITY.muted), + border: `1px solid ${theme.palette.border.light}`, + borderRadius: 2, + padding: '6px', + '&:hover': { + backgroundColor: theme.palette.surface.subtle, + borderColor: theme.palette.border.medium, + }, + }} + > + {showChart ? ( + + ) : ( + + )} + + + + + + + Rows: + + + + + + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ), + }} + sx={{ + width: '200px', + '& .MuiOutlinedInput-root': { + color: theme.palette.text.primary, + backgroundColor: alpha(theme.palette.common.black, 0.4), + fontSize: '0.8rem', + height: '36px', + borderRadius: 2, + '& fieldset': { borderColor: theme.palette.border.light }, + '&:hover fieldset': { + borderColor: theme.palette.border.medium, + }, + '&.Mui-focused fieldset': { borderColor: 'primary.main' }, + }, + }} + /> + +
+ + + + {showChart && filteredIssues.length > 0 && ( + + )} + + + + ); return ( - - - - - ID - - Repository - - Issue - - {/* Available Issues columns */} - {listType === 'available' && ( - <> - - Bounty - - - Status - - - )} - - {/* Pending Issues columns */} - {listType === 'pending' && ( - <> - - Target Bounty - - - Funding - - - Status - - - )} - - {/* History columns */} - {listType === 'history' && ( - <> - - Payout - - - Solver - - - Status - - - Date - - - )} - - - - {issues.map((issue) => { - const statusBadge = getStatusBadge(issue.status); - - return ( - onSelectIssue?.(issue.id)} - sx={{ - cursor: onSelectIssue ? 'pointer' : 'default', - transition: 'background-color 0.2s', - '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.03)', - }, - }} - > - {/* Common columns */} - - - #{issue.id} - - - - - - - {issue.repositoryFullName} - - - - - - {issue.title && ( - - {issue.title} - - )} - e.stopPropagation()} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 0.5, - fontFamily: '"JetBrains Mono", monospace', - fontSize: '0.75rem', - color: 'rgba(255, 255, 255, 0.5)', - textDecoration: 'none', - '&:hover': { - color: STATUS_COLORS.info, - textDecoration: 'underline', - }, - }} - > - #{issue.issueNumber} - - - - - - {/* Available Issues columns */} - {listType === 'available' && ( - <> - - - {formatTokenAmount(issue.targetBounty)} ل - - {toUsd(issue.targetBounty) && ( - - {toUsd(issue.targetBounty)} - - )} - - - - - - )} - - {/* Pending Issues columns */} - {listType === 'pending' && ( - <> - - - {formatTokenAmount(issue.targetBounty)} ل - - {toUsd(issue.targetBounty) && ( - - {toUsd(issue.targetBounty)} - - )} - - - - - - - - - )} - - {/* History columns */} - {listType === 'history' && ( - <> - - - {`${formatTokenAmount(issue.targetBounty)} ل`} - - {toUsd(issue.targetBounty) && ( - - {toUsd(issue.targetBounty)} - - )} - - - {issue.solverHotkey ? ( - - - {truncateAddress(issue.solverHotkey)} - - - ) : ( - - - - - )} - - - - - - - {formatDate(issue.completedAt || issue.updatedAt)} - - - - )} - - ); - })} - -
-
+ + columns={columns} + rows={paginatedIssues} + getRowKey={(issue) => issue.id} + getRowHref={ + getIssueHref ? (issue) => getIssueHref(issue.id) : undefined + } + linkState={linkState} + minWidth={ + filterType === 'history' + ? '1000px' + : filterType === 'pending' + ? '900px' + : '750px' + } + header={headerToolbar} + emptyState={ + + + {searchQuery ? 'No issues match your search' : 'No issues found'} + + + } + pagination={ + setPage(newPage)} + onRowsPerPageChange={() => {}} + showFirstButton + showLastButton + /> + } + sort={{ + field: sortKey, + order: sortDirection, + onChange: handleSort, + }} + />
); }; diff --git a/src/components/layout/AppLayout.tsx b/src/components/layout/AppLayout.tsx index 27254df6..44f03253 100644 --- a/src/components/layout/AppLayout.tsx +++ b/src/components/layout/AppLayout.tsx @@ -6,14 +6,16 @@ import { IconButton, AppBar, Toolbar, + alpha, } from '@mui/material'; import { Outlet, useLocation } from 'react-router-dom'; import MenuIcon from '@mui/icons-material/Menu'; import { LoadingPage } from '../../pages'; import useOnNavigate from '../../hooks/useOnNavigate'; import { Sidebar } from '..'; +import ErrorBoundary from '../ErrorBoundary'; import GlobalSearchBar from './GlobalSearchBar'; -import theme from '../../theme'; +import theme, { scrollbarSx } from '../../theme'; import { getRouteForPathname } from '../../routes'; const AppLayout: React.FC = () => { @@ -68,8 +70,7 @@ const AppLayout: React.FC = () => { style={{ height: '40px', width: 'auto', - filter: - 'brightness(0) invert(1) drop-shadow(0 0 6px rgba(255, 255, 255, 0.8))', + filter: `brightness(0) invert(1) drop-shadow(0 0 6px ${alpha(theme.palette.common.white, 0.8)})`, }} /> @@ -90,13 +91,12 @@ const AppLayout: React.FC = () => { '& .MuiDrawer-paper': { boxSizing: 'border-box', width: 280, - backgroundColor: '#000000', - backgroundImage: - 'linear-gradient(rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.05))', - borderRight: '1px solid rgba(255, 255, 255, 0.1)', + backgroundColor: 'background.default', + backgroundImage: `linear-gradient(${alpha(theme.palette.common.white, 0.05)}, ${alpha(theme.palette.common.white, 0.05)})`, + borderRight: `1px solid ${theme.palette.border.light}`, }, '& .MuiBackdrop-root': { - backgroundColor: 'rgba(0, 0, 0, 0.7)', + backgroundColor: alpha(theme.palette.common.black, 0.7), }, }} > @@ -111,7 +111,7 @@ const AppLayout: React.FC = () => { flexShrink: 0, width: '240px', minWidth: '240px', - borderRight: '1px solid rgba(255, 255, 255, 0.1)', + borderRight: `1px solid ${theme.palette.border.light}`, }} > @@ -126,12 +126,14 @@ const AppLayout: React.FC = () => { flexGrow: 1, maxWidth: '1920px', // Max content width for ultra-wide screens width: '100%', + height: { xs: 'calc(100vh - 64px)', md: '100vh' }, + mt: { xs: '64px', md: 0 }, overflowY: 'auto', overflowX: 'hidden', display: 'flex', flexDirection: 'column', px: { xs: 1, sm: 2, md: 3 }, - pt: isMobile ? '64px' : 0, // Padding for mobile header + ...scrollbarSx, alignItems: 'center', }} > @@ -140,19 +142,24 @@ const AppLayout: React.FC = () => { )} - + + +
diff --git a/src/components/layout/GlobalSearchBar.tsx b/src/components/layout/GlobalSearchBar.tsx index 93ebdaf7..9584a8cf 100644 --- a/src/components/layout/GlobalSearchBar.tsx +++ b/src/components/layout/GlobalSearchBar.tsx @@ -5,6 +5,7 @@ import React, { useRef, useState, } from 'react'; +import { scrollbarSx } from '../../theme'; import { Box, ButtonBase, @@ -22,6 +23,7 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import CloseIcon from '@mui/icons-material/Close'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { useSearchResults } from '../../pages/search/searchData'; +import { useLinkBehavior, linkResetSx } from '../common/linkBehavior'; const QUICK_RESULT_LIMIT = 3; const DROPDOWN_CLOSE_DELAY_MS = 150; @@ -35,6 +37,7 @@ type NavItem = { kind: NavItemKind; title: string; subtitle: string; + href?: string; onSelect: () => void; }; @@ -70,7 +73,6 @@ const rowSx = (theme: Theme, active: boolean) => ({ const subtitleSx = (theme: Theme) => ({ color: theme.palette.text.secondary, - fontFamily: theme.typography.mono.fontFamily, }); type SectionLabelProps = { @@ -101,6 +103,7 @@ type ResultRowProps = { active: boolean; rowRef: (el: HTMLButtonElement | null) => void; onMouseEnter: () => void; + onLinkClick: () => void; }; const ResultRow: React.FC = ({ @@ -108,18 +111,28 @@ const ResultRow: React.FC = ({ active, rowRef, onMouseEnter, + onLinkClick, }) => { const isAction = item.kind === 'action'; + const linkProps = useLinkBehavior(item.href ?? '', { + onClick: onLinkClick, + }); + const linkAnchorProps = item.href + ? { component: 'a' as const, ...linkProps } + : { onClick: item.onSelect }; return ( rowSx(theme, active)} + sx={(theme) => ({ + ...(item.href ? linkResetSx : null), + ...rowSx(theme, active), + })} onMouseDown={(e) => e.preventDefault()} onMouseEnter={onMouseEnter} - onClick={item.onSelect} + {...linkAnchorProps} > = ({ color: isAction ? theme.palette.primary.main : theme.palette.text.primary, - fontFamily: theme.typography.mono.fontFamily, fontSize: isAction ? '0.9rem' : '0.92rem', whiteSpace: 'nowrap', overflow: 'hidden', @@ -169,7 +181,6 @@ const EmptyState: React.FC = () => ( ({ color: theme.palette.text.primary, - fontFamily: theme.typography.mono.fontFamily, fontSize: '0.82rem', })} > @@ -294,49 +305,48 @@ const GlobalSearchBar: React.FC = () => { const navItems: NavItem[] = useMemo(() => { const items: NavItem[] = []; minerResults.forEach((miner) => { + const href = `/miners/details?githubId=${encodeURIComponent(miner.githubId)}`; items.push({ key: `miner-${miner.githubId}`, kind: 'miner', title: miner.githubUsername || miner.githubId, subtitle: getMinerSubtitle(miner), - onSelect: () => - navigateAndClose( - `/miners/details?githubId=${encodeURIComponent(miner.githubId)}`, - ), + href, + onSelect: () => navigateAndClose(href), }); }); repositoryResults.forEach((repo) => { + const href = `/miners/repository?name=${encodeURIComponent(repo.fullName)}`; items.push({ key: `repo-${repo.fullName}`, kind: 'repo', title: repo.fullName, subtitle: repo.owner, - onSelect: () => - navigateAndClose( - `/miners/repository?name=${encodeURIComponent(repo.fullName)}`, - ), + href, + onSelect: () => navigateAndClose(href), }); }); prResults.forEach((pr) => { + const href = `/miners/pr?repo=${encodeURIComponent(pr.repository)}&number=${pr.pullRequestNumber}`; items.push({ key: `pr-${pr.repository}-${pr.pullRequestNumber}`, kind: 'pr', title: `${pr.repository} #${pr.pullRequestNumber}`, subtitle: pr.pullRequestTitle, - onSelect: () => - navigateAndClose( - `/miners/pr?repo=${encodeURIComponent(pr.repository)}&number=${pr.pullRequestNumber}`, - ), + href, + onSelect: () => navigateAndClose(href), }); }); issueResults.forEach((issue) => { + const href = `/bounties/details?id=${issue.id}`; items.push({ key: `issue-${issue.id}`, kind: 'issue', title: issue.title || `${issue.repositoryFullName} #${issue.issueNumber}`, subtitle: `${issue.repositoryFullName} · #${issue.issueNumber}`, - onSelect: () => navigateAndClose(`/bounties/details?id=${issue.id}`), + href, + onSelect: () => navigateAndClose(href), }); }); if (trimmedQuery) { @@ -562,7 +572,6 @@ const GlobalSearchBar: React.FC = () => { borderRadius: 1, border: `1px solid ${theme.palette.border.light}`, color: theme.palette.text.secondary, - fontFamily: theme.typography.mono.fontFamily, fontSize: '0.68rem', lineHeight: 1.4, userSelect: 'none', @@ -577,7 +586,6 @@ const GlobalSearchBar: React.FC = () => { sx={(theme) => ({ '& .MuiOutlinedInput-root': { color: theme.palette.text.primary, - fontFamily: theme.typography.mono.fontFamily, backgroundColor: theme.palette.surface.subtle, fontSize: '0.85rem', borderRadius: 2, @@ -588,7 +596,6 @@ const GlobalSearchBar: React.FC = () => { }, }, '& .MuiInputBase-input::placeholder': { - fontFamily: theme.typography.mono.fontFamily, fontSize: '0.8rem', opacity: 0.75, }, @@ -612,16 +619,7 @@ const GlobalSearchBar: React.FC = () => { backgroundColor: theme.palette.background.default, maxHeight: 'min(420px, calc(100vh - 96px))', overflowY: 'auto', - '&::-webkit-scrollbar': { - width: '8px', - }, - '&::-webkit-scrollbar-track': { - backgroundColor: 'transparent', - }, - '&::-webkit-scrollbar-thumb': { - backgroundColor: theme.palette.border.light, - borderRadius: 1, - }, + ...scrollbarSx, })} > {isLoading && ( @@ -656,6 +654,7 @@ const GlobalSearchBar: React.FC = () => { active={idx === activeIndex} rowRef={getRowRef(item.key)} onMouseEnter={() => setActiveIndex(idx)} + onLinkClick={closeDropdown} /> ); diff --git a/src/components/layout/PageHeader.tsx b/src/components/layout/PageHeader.tsx deleted file mode 100644 index 921622b5..00000000 --- a/src/components/layout/PageHeader.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { - Box, - type BoxProps, - Breadcrumbs, - Container, - Typography, - Link, - Stack, -} from '@mui/material'; -import React from 'react'; - -export type Breadcrumb = { text: string; link?: string; action?: () => void }; - -export type PageHeaderProps = BoxProps & { - title: string; - breadcrumbs?: Breadcrumb[]; -}; - -const PageHeader: React.FC = ({ - children, - title, - breadcrumbs, - ...props -}) => ( - - - {breadcrumbs && ( - - {breadcrumbs && - breadcrumbs.map((breadcrumb) => - breadcrumb.link ? ( - - {breadcrumb.text} - - ) : ( - {breadcrumb.text} - ), - )} - - )} - - - {title} - - - {children} - - - - -); - -export default PageHeader; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 7da9287b..a5a2f4bf 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -7,25 +7,27 @@ import { ButtonBase, Divider, } from '@mui/material'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; +import { useLinkBehavior } from '../common/linkBehavior'; +import { useWatchlistTotalCount } from '../../hooks/useWatchlist'; interface SidebarProps { onNavigate?: () => void; } const Sidebar: React.FC = ({ onNavigate }) => { - const navigate = useNavigate(); const location = useLocation(); - - const handleNavigate = (path: string) => { - navigate(path); - onNavigate?.(); // Call onNavigate if provided (for mobile drawer closing) - }; + const watchlistCount = useWatchlistTotalCount(); const navItems = [ { label: 'dashboard', path: '/dashboard' }, { label: 'oss contributions', path: '/top-miners' }, { label: 'discoveries', path: '/discoveries', badge: 'new' }, + { + label: 'watchlist', + path: '/watchlist', + badge: watchlistCount > 0 ? String(watchlistCount) : undefined, + }, { label: 'bounties', path: '/bounties' }, { label: 'repositories', path: '/repositories' }, { label: 'onboard', path: '/onboard' }, @@ -43,16 +45,7 @@ const Sidebar: React.FC = ({ onNavigate }) => { }} > {/* Logo */} - handleNavigate('/')} - sx={{ - mb: 3, - justifyContent: 'center', - width: '100%', - py: 1, - }} - > + Gittensor = ({ onNavigate }) => { 'brightness(0) invert(1) drop-shadow(0 0 8px rgba(255, 255, 255, 0.8))', }} /> - + {/* Navigation */} {navItems.map((item) => ( - + path={item.path} + label={item.label} + badge={item.badge} + isActive={location.pathname.startsWith(item.path)} + onNavigate={onNavigate} + /> ))} @@ -117,7 +77,7 @@ const Sidebar: React.FC = ({ onNavigate }) => { {/* Footer */} - + = ({ onNavigate }) => { justifyContent="center" > = ({ onNavigate }) => { orientation="vertical" flexItem sx={{ - borderColor: '#3d3d3d', + borderColor: 'border.medium', mx: 0.5, height: '12px', alignSelf: 'center', }} /> = ({ onNavigate }) => { orientation="vertical" flexItem sx={{ - borderColor: '#3d3d3d', + borderColor: 'border.medium', mx: 0.5, height: '12px', alignSelf: 'center', }} /> = ({ onNavigate }) => { orientation="vertical" flexItem sx={{ - borderColor: '#3d3d3d', + borderColor: 'border.medium', mx: 0.5, height: '12px', alignSelf: 'center', }} /> = ({ onNavigate }) => { variant="caption" sx={{ fontSize: '0.6rem', - color: '#888888', + color: 'text.secondary', }} > © Gittensor 2026 @@ -232,4 +192,78 @@ const Sidebar: React.FC = ({ onNavigate }) => { ); }; +const SidebarLogoLink: React.FC<{ + onNavigate?: () => void; + children: React.ReactNode; +}> = ({ onNavigate, children }) => { + const linkProps = useLinkBehavior('/', { + onClick: () => onNavigate?.(), + }); + return ( + + {children} + + ); +}; + +const SidebarNavLink: React.FC<{ + path: string; + label: string; + badge?: string; + isActive: boolean; + onNavigate?: () => void; +}> = ({ path, label, badge, isActive, onNavigate }) => { + const linkProps = useLinkBehavior(path, { + onClick: () => onNavigate?.(), + }); + return ( + + ); +}; + export default Sidebar; diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts index 471a9653..c5232c72 100644 --- a/src/components/layout/index.ts +++ b/src/components/layout/index.ts @@ -4,9 +4,6 @@ export * from './AppLayout'; export { default as Page } from './Page'; export * from './Page'; -export { default as PageHeader } from './PageHeader'; -export * from './PageHeader'; - export { default as Sidebar } from './Sidebar'; export * from './Sidebar'; diff --git a/src/components/leaderboard/ActivitySidebarCards.tsx b/src/components/leaderboard/ActivitySidebarCards.tsx new file mode 100644 index 00000000..35dbc8ff --- /dev/null +++ b/src/components/leaderboard/ActivitySidebarCards.tsx @@ -0,0 +1,410 @@ +import React, { useMemo } from 'react'; +import { Box, Typography } from '@mui/material'; +import { alpha } from '@mui/material/styles'; +import { SectionCard } from './SectionCard'; +import { STATUS_COLORS, DIFF_COLORS, CREDIBILITY_COLORS } from '../../theme'; +import { credibilityColor } from '../../utils/format'; +import { type MinerStats, FONTS } from './types'; + +interface ActivitySidebarCardsProps { + miners: MinerStats[]; +} + +export const ActivitySidebarCards: React.FC = ({ + miners, +}) => { + const ossUsdPerDay = useMemo( + () => + miners + .filter((m) => m.ossIsEligible) + .reduce((acc, m) => acc + (m.usdPerDay || 0), 0), + [miners], + ); + + const issueUsdPerDay = useMemo( + () => + miners + .filter((m) => m.discoveriesIsEligible) + .reduce((acc, m) => acc + (m.usdPerDay || 0), 0), + [miners], + ); + + const prStats = useMemo(() => { + const merged = miners.reduce((acc, m) => acc + (m.totalMergedPrs || 0), 0); + const open = miners.reduce((acc, m) => acc + (m.totalOpenPrs || 0), 0); + const closed = miners.reduce((acc, m) => acc + (m.totalClosedPrs || 0), 0); + const total = merged + open + closed; + const mergeRate = total > 0 ? Math.round((merged / total) * 100) : 0; + return { merged, open, closed, mergeRate }; + }, [miners]); + + const issueStats = useMemo(() => { + const solved = miners.reduce( + (acc, m) => acc + (m.totalSolvedIssues || 0), + 0, + ); + const open = miners.reduce((acc, m) => acc + (m.totalOpenIssues || 0), 0); + const closed = miners.reduce( + (acc, m) => acc + (m.totalClosedIssues || 0), + 0, + ); + const total = solved + open + closed; + const solveRate = total > 0 ? Math.round((solved / total) * 100) : 0; + return { solved, open, closed, solveRate }; + }, [miners]); + + const codeStats = useMemo(() => { + const linesAdded = miners.reduce((acc, m) => acc + (m.linesAdded || 0), 0); + const linesDeleted = miners.reduce( + (acc, m) => acc + (m.linesDeleted || 0), + 0, + ); + const reposTouched = miners.reduce( + (acc, m) => acc + (m.uniqueReposCount || 0), + 0, + ); + const credibilityValues = miners + .map((m) => m.credibility) + .filter((c): c is number => typeof c === 'number'); + const avgCredibility = + credibilityValues.length > 0 + ? Math.round( + (credibilityValues.reduce((acc, c) => acc + c, 0) / + credibilityValues.length) * + 100, + ) + : 0; + return { linesAdded, linesDeleted, reposTouched, avgCredibility }; + }, [miners]); + + const solveRateColor = + issueStats.solveRate >= 80 + ? CREDIBILITY_COLORS.excellent + : issueStats.solveRate >= 50 + ? CREDIBILITY_COLORS.moderate + : STATUS_COLORS.closed; + + const mergeRateColor = + prStats.mergeRate >= 80 + ? CREDIBILITY_COLORS.excellent + : prStats.mergeRate >= 50 + ? CREDIBILITY_COLORS.moderate + : STATUS_COLORS.closed; + + return ( + <> + {/* CARD 1: PR Activity */} + + + ({ + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr', + gap: 1, + mb: 2, + pb: 2, + borderBottom: `1px solid ${theme.palette.border.light}`, + })} + > + + + + + + + + Merge Rate + + + + + {prStats.mergeRate}% + + + + + + + + + {/* CARD 2: Issue Activity */} + + + ({ + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr', + gap: 1, + mb: 2, + pb: 2, + borderBottom: `1px solid ${theme.palette.border.light}`, + })} + > + + + + + + + + Solve Rate + + + + + {issueStats.solveRate}% + + + + + + + + + {/* CARD 3: Code Impact */} + + + + + + + + Avg Credibility + + + + + {codeStats.avgCredibility}% + + + + + + + ); +}; + +// ── Shared sub-components ──────────────────────────────────────── + +export interface StatRowProps { + label: string; + value: number | string; + valueColor?: string; +} + +export const StatRow: React.FC = ({ + label, + value, + valueColor, +}) => ( + + + {label} + + ({ + fontFamily: FONTS.mono, + fontWeight: 600, + fontSize: '1.1rem', + color: valueColor ?? theme.palette.text.primary, + })} + > + {value} + + +); + +interface PRColumnProps { + label: string; + value: number; + color: string; +} + +const PRColumn: React.FC = ({ label, value, color }) => ( + + + {label} + + + {value.toLocaleString()} + + +); + +interface RateBarProps { + rate: number; + color: string; +} + +const RateBar: React.FC = ({ rate, color }) => ( + ({ + width: 64, + height: 6, + borderRadius: 3, + backgroundColor: alpha(theme.palette.text.primary, 0.1), + overflow: 'hidden', + })} + > + + +); diff --git a/src/components/leaderboard/LeaderboardSidebar.tsx b/src/components/leaderboard/LeaderboardSidebar.tsx index 495ee611..6c379baf 100644 --- a/src/components/leaderboard/LeaderboardSidebar.tsx +++ b/src/components/leaderboard/LeaderboardSidebar.tsx @@ -2,22 +2,26 @@ import React, { useMemo, useState } from 'react'; import { Box, Stack, Typography, Avatar } from '@mui/material'; import { alpha } from '@mui/material/styles'; import { SectionCard } from './SectionCard'; -import { STATUS_COLORS } from '../../theme'; +import { STATUS_COLORS, scrollbarSx } from '../../theme'; import { getGithubAvatarSrc } from '../../utils/ExplorerUtils'; +import { LinkBox } from '../common/linkBehavior'; import { type MinerStats, FONTS } from './types'; +import { ActivitySidebarCards } from './ActivitySidebarCards'; // Re-export MinerStats for backward compatibility export type { MinerStats } from './types'; interface LeaderboardSidebarProps { miners: MinerStats[]; - onSelectMiner: (githubId: string) => void; + getMinerHref: (miner: MinerStats) => string; + linkState?: Record; variant?: 'oss' | 'discoveries'; } export const LeaderboardSidebar: React.FC = ({ miners, - onSelectMiner, + getMinerHref, + linkState, variant = 'oss', }) => { // State for toggling lists @@ -37,54 +41,24 @@ export const LeaderboardSidebar: React.FC = ({ const mostActive = useMemo( () => [...miners] - .sort((a, b) => (b.totalPRs || 0) - (a.totalPRs || 0)) + .sort((a, b) => + variant === 'discoveries' + ? (b.totalIssues || 0) - (a.totalIssues || 0) + : (b.totalPRs || 0) - (a.totalPRs || 0), + ) .slice(0, 5), - [miners], - ); - - // Network Stats Data - const networkStats = useMemo( - () => ({ - totalMiners: miners.length, - eligible: miners.filter((m) => m.isEligible).length, - totalPRs: miners.reduce((acc, m) => acc + (m.totalPRs || 0), 0), - dailyPool: miners.reduce((acc, m) => acc + (m.usdPerDay || 0), 0), - }), - [miners], + [miners, variant], ); return ( - - {/* CARD 1: Network Stats */} - - - - - - - - + + {/* Activity Cards: PR Activity, Issue Activity, Code Impact */} + - {/* CARD 2: Leaderboard Lists (Tabs) */} + {/* Leaderboard Lists (Tabs) */} = ({ miner={miner} rank={i + 1} type={leaderboardType} - onClick={() => - onSelectMiner(miner.githubId || miner.author || '') - } + variant={variant} + href={getMinerHref(miner)} + linkState={linkState} /> ), )} @@ -117,42 +91,6 @@ export const LeaderboardSidebar: React.FC = ({ ); }; -interface StatRowProps { - label: string; - value: number | string; - valueColor?: string; -} - -const StatRow: React.FC = ({ label, value, valueColor }) => ( - - - {label} - - ({ - fontFamily: FONTS.mono, - fontWeight: 600, - fontSize: '1.1rem', - color: valueColor ?? theme.palette.text.primary, - })} - > - {value} - - -); - interface LeaderboardTabsProps { activeTab: 'earners' | 'active'; onTabChange: (tab: 'earners' | 'active') => void; @@ -279,78 +217,88 @@ interface LeaderboardRowProps { miner: MinerStats; rank: number; type: 'earners' | 'active'; - onClick: () => void; + variant?: 'oss' | 'discoveries'; + href: string; + linkState?: Record; } const LeaderboardRow: React.FC = ({ miner, rank, type, - onClick, -}) => ( - ({ - display: 'flex', - alignItems: 'center', - py: 1, - cursor: 'pointer', - '&:hover': { - backgroundColor: alpha(theme.palette.text.primary, 0.03), - borderRadius: 1, - }, - })} - > - - {rank} - - { + return ( + ({ display: 'flex', alignItems: 'center', - gap: 1, - flex: 1, - minWidth: 0, - }} + py: 1, + px: 2, + mx: -2, + cursor: 'pointer', + '&:hover': { + backgroundColor: alpha(theme.palette.text.primary, 0.03), + }, + })} > - ({ + sx={{ fontFamily: FONTS.mono, fontSize: '0.85rem', - color: theme.palette.text.tertiary, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', + color: STATUS_COLORS.open, + width: 24, + }} + > + {rank} + + + + ({ + fontFamily: FONTS.mono, + fontSize: '0.85rem', + color: theme.palette.text.tertiary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + })} + > + {miner.author || miner.githubId} + + + ({ + fontFamily: FONTS.mono, + fontSize: '0.95rem', + color: + type === 'earners' + ? STATUS_COLORS.merged + : theme.palette.text.primary, + fontWeight: 600, })} > - {miner.author || miner.githubId} + {type === 'earners' + ? `$${Math.round(miner.usdPerDay || 0).toLocaleString()}` + : variant === 'discoveries' + ? miner.totalIssues + : miner.totalPRs} - - ({ - fontFamily: FONTS.mono, - fontSize: '0.95rem', - color: - type === 'earners' - ? STATUS_COLORS.merged - : theme.palette.text.primary, - fontWeight: 600, - })} - > - {type === 'earners' - ? `$${Math.round(miner.usdPerDay || 0).toLocaleString()}` - : miner.totalPRs} - - -); + + ); +}; diff --git a/src/components/leaderboard/LeaderboardTableSkeleton.tsx b/src/components/leaderboard/LeaderboardTableSkeleton.tsx new file mode 100644 index 00000000..9ca94c20 --- /dev/null +++ b/src/components/leaderboard/LeaderboardTableSkeleton.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { Skeleton, TableCell, TableRow } from '@mui/material'; +import { bodyCellStyle } from './types'; + +type SkeletonVariant = 'miners' | 'prs' | 'repositories'; + +interface LeaderboardTableSkeletonProps { + variant: SkeletonVariant; + rows?: number; +} + +const LAYOUTS: Record< + SkeletonVariant, + Array<{ width: string; barWidth: string; align?: 'right' }> +> = { + miners: [ + { width: '80px', barWidth: '24px' }, + { width: '25%', barWidth: '60%' }, + { width: '15%', barWidth: '40%', align: 'right' }, + { width: '15%', barWidth: '40%', align: 'right' }, + { width: '15%', barWidth: '40%', align: 'right' }, + { width: '15%', barWidth: '50%', align: 'right' }, + ], + prs: [ + { width: '80px', barWidth: '24px' }, + { width: '40%', barWidth: '80%' }, + { width: '20%', barWidth: '60%' }, + { width: '20%', barWidth: '60%' }, + { width: '10%', barWidth: '50%' }, + { width: '15%', barWidth: '55%', align: 'right' }, + ], + repositories: [ + { width: '60px', barWidth: '24px' }, + { width: '35%', barWidth: '70%' }, + { width: '12%', barWidth: '50%', align: 'right' }, + { width: '18%', barWidth: '55%', align: 'right' }, + { width: '15%', barWidth: '40%', align: 'right' }, + { width: '15%', barWidth: '40%', align: 'right' }, + ], +}; + +const LeaderboardTableSkeleton: React.FC = ({ + variant, + rows = 10, +}) => { + const cols = LAYOUTS[variant]; + return ( + <> + {Array.from({ length: rows }).map((_, rowIdx) => ( + + ))} + + ); +}; + +export default LeaderboardTableSkeleton; diff --git a/src/components/leaderboard/MinerCard.tsx b/src/components/leaderboard/MinerCard.tsx index 6c1097e7..d2370a7c 100644 --- a/src/components/leaderboard/MinerCard.tsx +++ b/src/components/leaderboard/MinerCard.tsx @@ -1,41 +1,230 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { Box, Card, Typography, Avatar } from '@mui/material'; import { alpha, useTheme } from '@mui/material/styles'; import ReactECharts from 'echarts-for-react'; import { useMinerGithubData, useMinerPRs } from '../../api'; import { CHART_COLORS, STATUS_COLORS } from '../../theme'; import { getGithubAvatarSrc } from '../../utils/ExplorerUtils'; -import { type MinerStats, FONTS } from './types'; +import { linkResetSx, useLinkBehavior } from '../common/linkBehavior'; +import { WatchlistButton } from '../common'; +import { type MinerStats, type LeaderboardVariant, FONTS } from './types'; interface MinerCardProps { miner: MinerStats; - onClick: () => void; + href: string; + linkState?: Record; + variant?: LeaderboardVariant; + showDualEligibilityBadges?: boolean; } const INACTIVE_OPACITY = 0.24; -export const MinerCard: React.FC = ({ miner, onClick }) => { - const muiTheme = useTheme(); - const isNumericId = (value?: string) => !value || /^\d+$/.test(value); - const shouldFetch = !!miner.githubId && isNumericId(miner.author); +const CHART_SEGMENT_COLORS = [ + CHART_COLORS.merged, + CHART_COLORS.open, + CHART_COLORS.closed, +]; +const CHART_INACTIVE_RATIOS = [2 / 3, 1, 1 / 2]; + +interface Segment { + label: string; + value: number; +} + +const getPrSegments = (miner: MinerStats): Segment[] => [ + { label: 'Merged', value: miner.totalMergedPrs ?? 0 }, + { label: 'Open', value: miner.totalOpenPrs ?? 0 }, + { label: 'Closed', value: miner.totalClosedPrs ?? 0 }, +]; + +const getIssueSegments = (miner: MinerStats): Segment[] => [ + { label: 'Solved', value: miner.totalSolvedIssues ?? 0 }, + { label: 'Open', value: miner.totalOpenIssues ?? 0 }, + { label: 'Closed', value: miner.totalClosedIssues ?? 0 }, +]; + +const getSegments = ( + miner: MinerStats, + variant: LeaderboardVariant, +): Segment[] => + variant === 'discoveries' ? getIssueSegments(miner) : getPrSegments(miner); + +const isNumericGithubAuthor = (value?: string) => !value || /^\d+$/.test(value); + +export const MinerCard: React.FC = ({ + miner, + href, + linkState, + variant = 'oss', + showDualEligibilityBadges = false, +}) => { + const linkProps = useLinkBehavior(href, { state: linkState }); + const shouldFetch = !!miner.githubId && isNumericGithubAuthor(miner.author); const { data: githubData } = useMinerGithubData(miner.githubId, shouldFetch); const { data: prs } = useMinerPRs(miner.githubId, shouldFetch); const username = githubData?.login || prs?.[0]?.author || - (!isNumericId(miner.author) ? miner.author : miner.githubId) || + (!isNumericGithubAuthor(miner.author) ? miner.author : miner.githubId) || miner.githubId || ''; const avatarSrc = githubData?.avatarUrl || getGithubAvatarSrc(username); const credibilityPercent = (miner.credibility ?? 0) * 100; - const isEligible = miner.isEligible ?? false; + const issueCredPercent = (miner.issueCredibility ?? 0) * 100; + const ossEligible = miner.ossIsEligible ?? miner.isEligible ?? false; + const discoveriesEligible = + miner.discoveriesIsEligible ?? + miner.isIssueEligible ?? + (variant === 'discoveries' ? (miner.isEligible ?? false) : false); + const isWatchlist = variant === 'watchlist'; + const isDiscoveries = variant === 'discoveries'; + const baseEligible = miner.isEligible ?? false; + const isEligible = isWatchlist + ? ossEligible || discoveriesEligible || baseEligible + : showDualEligibilityBadges + ? ossEligible || discoveriesEligible + : baseEligible; + + const segments = getSegments(miner, variant); + + const minerIdentity = ( + + ({ + width: 36, + height: 36, + border: '2px solid', + borderColor: isEligible + ? alpha(theme.palette.status.merged, 0.3) + : theme.palette.border.subtle, + filter: isEligible ? 'none' : 'grayscale(100%)', + opacity: isEligible ? 1 : INACTIVE_OPACITY, + flexShrink: 0, + })} + /> + + ({ + fontFamily: FONTS.mono, + fontSize: '1rem', + fontWeight: 700, + color: isEligible + ? theme.palette.text.primary + : theme.palette.text.tertiary, + opacity: isEligible ? 1 : INACTIVE_OPACITY, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + })} + > + {username} + + ({ + fontFamily: FONTS.mono, + fontSize: '0.72rem', + fontWeight: 700, + color: isEligible + ? theme.palette.status.merged + : theme.palette.text.tertiary, + opacity: isEligible ? 1 : INACTIVE_OPACITY, + lineHeight: 1, + mt: 0.1, + })} + > + #{miner.rank} + + + + ); + + const dualEligibilityPills = ( + <> + ({ + fontFamily: FONTS.mono, + fontSize: { xs: '0.6rem', sm: '0.65rem' }, + fontWeight: 700, + textTransform: 'uppercase', + border: `1px solid ${ + ossEligible + ? alpha(theme.palette.status.merged, 0.45) + : theme.palette.border.subtle + }`, + borderRadius: 1, + px: { xs: 0.65, sm: 0.75 }, + py: 0.35, + letterSpacing: { xs: '0.04em', sm: '0.06em' }, + color: ossEligible + ? theme.palette.status.merged + : theme.palette.text.secondary, + backgroundColor: ossEligible + ? alpha(theme.palette.status.merged, 0.08) + : theme.palette.surface.subtle, + lineHeight: 1.2, + flexShrink: 0, + whiteSpace: 'nowrap', + })} + > + OSS {ossEligible ? 'Eligible' : 'Ineligible'} + + ({ + fontFamily: FONTS.mono, + fontSize: { xs: '0.6rem', sm: '0.65rem' }, + fontWeight: 700, + textTransform: 'uppercase', + border: `1px solid ${ + discoveriesEligible + ? alpha(theme.palette.status.merged, 0.45) + : theme.palette.border.subtle + }`, + borderRadius: 1, + px: { xs: 0.65, sm: 0.75 }, + py: 0.35, + letterSpacing: { xs: '0.04em', sm: '0.06em' }, + color: discoveriesEligible + ? theme.palette.status.merged + : theme.palette.text.secondary, + backgroundColor: discoveriesEligible + ? alpha(theme.palette.status.merged, 0.08) + : theme.palette.surface.subtle, + lineHeight: 1.2, + flexShrink: 0, + whiteSpace: 'nowrap', + })} + > + Issues {discoveriesEligible ? 'Eligible' : 'Ineligible'} + + + ); return ( ({ + ...linkResetSx, p: 1, backgroundColor: isEligible ? theme.palette.background.default @@ -71,94 +260,139 @@ export const MinerCard: React.FC = ({ miner, onClick }) => { })} elevation={0} > - - - ({ - width: 36, - height: 36, - border: '2px solid', - borderColor: isEligible - ? alpha(theme.palette.status.merged, 0.3) - : theme.palette.border.subtle, - filter: isEligible ? 'none' : 'grayscale(100%)', - opacity: isEligible ? 1 : INACTIVE_OPACITY, - flexShrink: 0, - })} - /> + {showDualEligibilityBadges ? ( + <> - ({ - fontFamily: FONTS.mono, - fontSize: '1rem', - fontWeight: 700, - color: isEligible - ? theme.palette.text.primary - : theme.palette.text.tertiary, - opacity: isEligible ? 1 : INACTIVE_OPACITY, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - })} + - {username} - - ({ - fontFamily: FONTS.mono, - fontSize: '0.72rem', - fontWeight: 700, - color: isEligible - ? theme.palette.status.merged - : theme.palette.text.tertiary, - opacity: isEligible ? 1 : INACTIVE_OPACITY, - lineHeight: 1, - mt: 0.1, - })} + {minerIdentity} + {miner.githubId && ( + + )} + + - #{miner.rank} - + {dualEligibilityPills} + -
- {!isEligible && ( - ({ - fontFamily: FONTS.mono, - fontSize: '0.65rem', - fontWeight: 700, - color: theme.palette.text.secondary, - textTransform: 'uppercase', - border: `1px solid ${theme.palette.border.subtle}`, - borderRadius: 1, - px: 0.75, - py: 0.25, - letterSpacing: '0.06em', + + {minerIdentity} + + + {dualEligibilityPills} + + {miner.githubId && ( + + )} + + + + ) : ( + + {minerIdentity} + - Ineligible - - )} - + {!isEligible ? ( + ({ + fontFamily: FONTS.mono, + fontSize: '0.65rem', + fontWeight: 700, + color: theme.palette.text.secondary, + textTransform: 'uppercase', + border: `1px solid ${theme.palette.border.subtle}`, + borderRadius: 1, + px: 0.75, + py: 0.25, + letterSpacing: '0.06em', + backgroundColor: theme.palette.surface.subtle, + })} + > + Ineligible + + ) : null} + {miner.githubId && ( + + )} + + + )} = ({ miner, onClick }) => { - - - - ({ - fontFamily: FONTS.mono, - fontSize: '0.75rem', - color: isEligible - ? credibilityPercent >= 80 - ? STATUS_COLORS.merged - : STATUS_COLORS.open - : theme.palette.text.tertiary, - fontWeight: 700, - })} - > - {credibilityPercent.toFixed(0)}% - + {isWatchlist ? ( + + + - + ) : ( + + )} + + + ); +}; + +interface MinerCardFooterProps { + miner: MinerStats; + totalScore: number; + segments: Segment[]; + isEligible: boolean; + variant: LeaderboardVariant; +} + +const MinerCardFooter: React.FC = ({ + miner, + totalScore, + segments, + isEligible, + variant, +}) => { + const muiTheme = useTheme(); + const inactiveColor = alpha(muiTheme.palette.text.tertiary, INACTIVE_OPACITY); + + const statColors = isEligible + ? [ + STATUS_COLORS.merged, + alpha(muiTheme.palette.text.primary, 0.84), + muiTheme.palette.status.closed, + ] + : [inactiveColor, inactiveColor, inactiveColor]; + + const issueSegments = getIssueSegments(miner); + const issueTotal = + (miner.totalSolvedIssues ?? 0) + + (miner.totalOpenIssues ?? 0) + + (miner.totalClosedIssues ?? 0); + + return ( + ({ + display: 'flex', + flexDirection: 'column', + gap: variant === 'discoveries' || variant === 'watchlist' ? 0.75 : 0, + backgroundColor: isEligible + ? alpha(theme.palette.background.default, 0.2) + : theme.palette.surface.subtle, + opacity: isEligible ? 1 : 0.62, + borderRadius: 1.5, + p: 1, + })} + > ({ + sx={{ display: 'grid', - gridTemplateColumns: '1fr 1fr 1fr auto', + gridTemplateColumns: '1fr 1fr 1fr 4.5rem', gap: 1, - backgroundColor: isEligible - ? alpha(theme.palette.background.default, 0.2) - : theme.palette.surface.subtle, - opacity: isEligible ? 1 : 0.62, - borderRadius: 1.5, - p: 1, alignItems: 'center', - })} + }} > - {[ - { - label: 'Merged', - value: miner.totalMergedPrs ?? 0, - color: isEligible - ? STATUS_COLORS.merged - : alpha(muiTheme.palette.text.tertiary, INACTIVE_OPACITY), - }, - { - label: 'Open', - value: miner.totalOpenPrs ?? 0, - color: isEligible - ? alpha(muiTheme.palette.text.primary, 0.84) - : alpha(muiTheme.palette.text.tertiary, INACTIVE_OPACITY), - }, - { - label: 'Closed', - value: miner.totalClosedPrs ?? 0, - color: isEligible - ? muiTheme.palette.status.closed - : alpha(muiTheme.palette.text.tertiary, INACTIVE_OPACITY), - }, - ].map(({ label, value, color }) => ( - - ({ - fontFamily: FONTS.mono, - fontSize: '0.6rem', - color: isEligible - ? theme.palette.status.open - : alpha(muiTheme.palette.text.tertiary, INACTIVE_OPACITY), - textTransform: 'uppercase', - mb: 0.2, - })} - > - {label} - - - {value} - - + {segments.map((segment, i) => ( + ))} ({ @@ -379,33 +556,250 @@ export const MinerCard: React.FC = ({ miner, onClick }) => { pl: 1.5, })} > + Score ({ + sx={{ fontFamily: FONTS.mono, - fontSize: '0.6rem', - color: isEligible - ? theme.palette.status.open - : alpha(muiTheme.palette.text.tertiary, INACTIVE_OPACITY), - textTransform: 'uppercase', - mb: 0.2, - })} + fontSize: '0.9rem', + color: isEligible ? muiTheme.palette.text.primary : inactiveColor, + fontWeight: 700, + }} > - Score + {Number(totalScore).toFixed(2)} + + + + {variant === 'watchlist' && ( + ({ + pt: 0.35, + borderTop: `1px solid ${theme.palette.border.light}`, + })} + > - {Number(miner.totalScore).toFixed(2)} + Issues + + + {issueSegments.map((segment, i) => ( + + ))} + ({ + textAlign: 'right', + borderLeft: `1px solid ${ + isEligible + ? theme.palette.border.light + : theme.palette.border.subtle + }`, + pl: 1.5, + })} + > + Total + + {issueTotal} + + + + + )} + + ); +}; + +interface StatCellProps { + label: string; + value: number; + color: string; + isEligible: boolean; +} + +const StatCell: React.FC = ({ + label, + value, + color, + isEligible, +}) => ( + + {label} + + {value} + + +); + +const StatLabel: React.FC<{ + isEligible: boolean; + children: React.ReactNode; +}> = ({ isEligible, children }) => ( + ({ + fontFamily: FONTS.mono, + fontSize: '0.6rem', + color: isEligible + ? theme.palette.status.open + : alpha(theme.palette.text.tertiary, INACTIVE_OPACITY), + textTransform: 'uppercase', + mb: 0.2, + })} + > + {children} + +); + +interface CredDonutProps { + segments: Segment[]; + percent: number; + isEligible: boolean; + label?: string; + size?: number; +} + +const CredDonut: React.FC = ({ + segments, + percent, + isEligible, + label, + size = 48, +}) => { + const muiTheme = useTheme(); + const segmentFingerprint = segments.map((s) => s.value).join(','); + + const chartOption = useMemo(() => { + const values = + segmentFingerprint === '' + ? [] + : segmentFingerprint.split(',').map((v) => Number(v)); + + return { + backgroundColor: 'transparent', + series: [ + { + type: 'pie' as const, + radius: ['65%', '90%'], + silent: true, + label: { show: false }, + itemStyle: { borderRadius: 3, borderWidth: 0 }, + data: values.map((value, i) => ({ + value, + itemStyle: { + color: isEligible + ? CHART_SEGMENT_COLORS[i] + : alpha( + muiTheme.palette.text.secondary, + INACTIVE_OPACITY * CHART_INACTIVE_RATIOS[i], + ), + }, + })), + }, + ], + }; + }, [segmentFingerprint, isEligible, muiTheme]); + + return ( + + + + + ({ + fontFamily: FONTS.mono, + fontSize: size <= 48 ? '0.65rem' : '0.75rem', + color: isEligible + ? percent >= 80 + ? STATUS_COLORS.merged + : STATUS_COLORS.open + : theme.palette.text.tertiary, + fontWeight: 700, + })} + > + {percent.toFixed(0)}% - + {label && ( + ({ + fontFamily: FONTS.mono, + fontSize: '0.55rem', + color: theme.palette.status.open, + textTransform: 'uppercase', + mt: 0.25, + letterSpacing: '0.04em', + })} + > + {label} + + )} + ); }; diff --git a/src/components/leaderboard/MinersList.tsx b/src/components/leaderboard/MinersList.tsx new file mode 100644 index 00000000..ac5e6dd8 --- /dev/null +++ b/src/components/leaderboard/MinersList.tsx @@ -0,0 +1,281 @@ +import React from 'react'; +import { Avatar, Box, Card, Tooltip, Typography } from '@mui/material'; +import { useMinerGithubData, useMinerPRs } from '../../api'; +import { CHART_COLORS, scrollbarSx } from '../../theme'; +import { getGithubAvatarSrc, type SortOrder } from '../../utils/ExplorerUtils'; +import { DataTable, type DataTableColumn, WatchlistButton } from '../common'; +import { RankIcon } from './RankIcon'; +import { + type LeaderboardVariant, + type MinerStats, + type SortOption, +} from './types'; + +const SEGMENT_COLORS = [ + CHART_COLORS.merged, + CHART_COLORS.open, + CHART_COLORS.closed, +]; + +const cellTypographySx = { + fontSize: '0.75rem', + fontWeight: 600, +} as const; + +interface MinersListProps { + miners: MinerStats[]; + variant: LeaderboardVariant; + sortOption: SortOption; + sortDirection: SortOrder; + onSort: (option: SortOption) => void; + getHref: (miner: MinerStats) => string; + linkState?: Record; +} + +export const MinersList: React.FC = ({ + miners, + variant, + sortOption, + sortDirection, + onSort, + getHref, + linkState, +}) => { + const isDiscoveries = variant === 'discoveries'; + const prLabel = isDiscoveries ? 'Issues' : 'PRs'; + const prSortKey: SortOption = isDiscoveries ? 'totalIssues' : 'totalPRs'; + + const columns: DataTableColumn[] = [ + { + key: 'rank', + header: 'Rank', + width: '60px', + cellSx: { pr: 0 }, + renderCell: (miner) => , + }, + { + key: 'miner', + header: 'Miner', + width: '25%', + cellSx: { pl: 1.5 }, + renderCell: (miner) => , + }, + { + key: 'usdPerDay', + header: 'Earnings/day', + width: '14%', + align: 'right', + sortKey: 'usdPerDay', + renderCell: (miner) => ( + + ${Math.round(miner.usdPerDay || 0).toLocaleString()} + + ), + }, + { + key: 'activity', + header: prLabel, + width: '18%', + align: 'right', + sortKey: prSortKey, + renderCell: (miner) => ( + + ), + }, + { + key: 'credibility', + header: 'Credibility', + width: '12%', + align: 'right', + sortKey: 'credibility', + renderCell: (miner) => ( + + {((miner.credibility ?? 0) * 100).toFixed(0)}% + + ), + }, + { + key: 'totalScore', + header: 'Score', + width: '11%', + align: 'right', + sortKey: 'totalScore', + renderCell: (miner) => ( + + {Number(miner.totalScore).toFixed(2)} + + ), + }, + { + key: 'watch', + header: '\u2605', + width: '52px', + align: 'center', + cellSx: { p: 0 }, + renderCell: (miner) => + miner.githubId ? ( + + ) : null, + }, + ]; + + return ( + + + columns={columns} + rows={miners} + getRowKey={(miner) => miner.id} + getRowHref={getHref} + linkState={linkState} + getRowSx={(miner) => ({ + opacity: (miner.isEligible ?? false) ? 1 : 0.5, + transition: 'opacity 0.2s, background-color 0.2s', + })} + minWidth="900px" + stickyHeader + sort={{ + field: sortOption, + order: sortDirection, + onChange: onSort, + }} + /> + + ); +}; + +interface MinerIdentityCellProps { + miner: MinerStats; +} + +const MinerIdentityCell: React.FC = ({ miner }) => { + const isNumericId = (value?: string) => !value || /^\d+$/.test(value); + const shouldFetch = !!miner.githubId && isNumericId(miner.author); + const { data: githubData } = useMinerGithubData(miner.githubId, shouldFetch); + const { data: prs } = useMinerPRs(miner.githubId, shouldFetch); + + const username = + githubData?.login || + prs?.[0]?.author || + (!isNumericId(miner.author) ? miner.author : miner.githubId) || + miner.githubId || + ''; + const avatarSrc = githubData?.avatarUrl || getGithubAvatarSrc(username); + + return ( + + + + + {username} + + + + ); +}; + +interface MinerActivitySegmentsProps { + miner: MinerStats; + variant: LeaderboardVariant; +} + +const MinerActivitySegments: React.FC = ({ + miner, + variant, +}) => { + const segments = + variant === 'discoveries' + ? [ + { label: 'Solved', value: miner.totalSolvedIssues ?? 0 }, + { label: 'Open', value: miner.totalOpenIssues ?? 0 }, + { label: 'Closed', value: miner.totalClosedIssues ?? 0 }, + ] + : [ + { label: 'Merged', value: miner.totalMergedPrs ?? 0 }, + { label: 'Open', value: miner.totalOpenPrs ?? 0 }, + { label: 'Closed', value: miner.totalClosedPrs ?? 0 }, + ]; + + return ( + + {segments.map((segment, i) => ( + + + + + {segment.value} + + + + ))} + + ); +}; diff --git a/src/components/leaderboard/RankIcon.tsx b/src/components/leaderboard/RankIcon.tsx index b06f24e5..a54a6c7f 100644 --- a/src/components/leaderboard/RankIcon.tsx +++ b/src/components/leaderboard/RankIcon.tsx @@ -15,7 +15,7 @@ export const RankIcon: React.FC<{ rank: number }> = ({ rank }) => { return ( = ({ rank }) => { justifyContent: 'center', flexShrink: 0, border: '1px solid', - borderColor: color ? alpha(color, 0.4) : 'rgba(255, 255, 255, 0.15)', + borderColor: color ? alpha(color, 0.4) : 'border.light', boxShadow: color ? `0 0 12px ${alpha(color, 0.4)}, 0 0 4px ${alpha(color, 0.2)}` : 'none', @@ -33,8 +33,7 @@ export const RankIcon: React.FC<{ rank: number }> = ({ rank }) => { ; +} + +const INACTIVE_OPACITY = 0.5; + +const formatMetric = (value: number, decimals = 0): string => + value > 0 ? (decimals > 0 ? value.toFixed(decimals) : String(value)) : '-'; + +interface MetricCellProps { + label: string; + value: string; +} + +const MetricCell: React.FC = ({ label, value }) => ( + + ({ + fontFamily: FONTS.mono, + fontSize: '0.65rem', + color: theme.palette.text.tertiary, + textTransform: 'uppercase', + letterSpacing: '0.04em', + whiteSpace: 'nowrap', + })} + > + {label} + + + {value} + + +); + +export const RepositoryCard: React.FC = ({ + repo, + maxWeight, + href, + linkState, +}) => { + const owner = (repo.repository || '').split('/')[0] || ''; + const isInactive = !!repo.inactiveAt; + const weightPct = + maxWeight > 0 + ? Math.max(0, Math.min(100, (repo.weight / maxWeight) * 100)) + : 0; + const linkProps = useLinkBehavior(href, { + state: linkState, + }); + + return ( + ({ + ...linkResetSx, + p: 2, + height: '100%', + borderRadius: 2, + border: '1px solid', + borderColor: theme.palette.border.light, + backgroundColor: theme.palette.surface.transparent, + display: 'flex', + flexDirection: 'column', + gap: 1.5, + cursor: 'pointer', + transition: 'all 0.2s', + opacity: isInactive ? INACTIVE_OPACITY : 1, + '&:hover': { + backgroundColor: theme.palette.surface.light, + borderColor: theme.palette.border.medium, + }, + '&:focus-visible': { + outline: '2px solid', + outlineColor: theme.palette.primary.main, + outlineOffset: '2px', + }, + })} + elevation={0} + > + + + ({ + width: 28, + height: 28, + flexShrink: 0, + border: '1px solid', + borderColor: theme.palette.border.medium, + backgroundColor: getRepositoryOwnerAvatarBackground(owner), + })} + /> + + + {repo.repository} + + + ({ + fontFamily: FONTS.mono, + fontSize: '0.65rem', + fontWeight: 600, + textTransform: 'uppercase', + letterSpacing: '0.04em', + px: 0.75, + py: 0.25, + borderRadius: '4px', + flexShrink: 0, + color: isInactive + ? theme.palette.status.closed + : theme.palette.status.success, + backgroundColor: isInactive + ? alpha(theme.palette.status.closed, 0.12) + : alpha(theme.palette.status.success, 0.12), + })} + > + {isInactive ? 'Inactive' : 'Active'} + + + + + + ({ + fontFamily: FONTS.mono, + fontSize: '0.65rem', + color: theme.palette.text.tertiary, + textTransform: 'uppercase', + letterSpacing: '0.04em', + })} + > + Weight + + + {repo.weight.toFixed(2)} + + + + + + + + + + + ); +}; + +export default RepositoryCard; diff --git a/src/components/leaderboard/SectionCard.tsx b/src/components/leaderboard/SectionCard.tsx index d146e54b..71fdd7e3 100644 --- a/src/components/leaderboard/SectionCard.tsx +++ b/src/components/leaderboard/SectionCard.tsx @@ -23,8 +23,9 @@ export const SectionCard: React.FC<{ @@ -83,15 +85,17 @@ export const SectionCard: React.FC<{ {hasAction && ( - {action} + {action} )} diff --git a/src/components/leaderboard/TopMinersTable.tsx b/src/components/leaderboard/TopMinersTable.tsx index e5bdac40..60d60508 100644 --- a/src/components/leaderboard/TopMinersTable.tsx +++ b/src/components/leaderboard/TopMinersTable.tsx @@ -1,55 +1,248 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Box, Typography, CircularProgress, Grid } from '@mui/material'; -import { alpha, type Theme } from '@mui/material/styles'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import SearchIcon from '@mui/icons-material/Search'; +import { + Box, + Typography, + CircularProgress, + Grid, + IconButton, + useMediaQuery, + Tooltip, +} from '@mui/material'; +import { alpha, useTheme, type Theme } from '@mui/material/styles'; +import ViewModuleIcon from '@mui/icons-material/ViewModule'; +import ViewListIcon from '@mui/icons-material/ViewList'; import { SectionCard } from './SectionCard'; import { MinerCard } from './MinerCard'; +import { MinersList } from './MinersList'; import { SearchInput } from '../common/SearchInput'; import { STATUS_COLORS } from '../../theme'; -import { type MinerStats, type SortOption, FONTS } from './types'; +import { useDataTableParams } from '../../hooks/useDataTableParams'; +import { type SortOrder } from '../../utils/ExplorerUtils'; +import { + type MinerStats, + type SortOption, + type LeaderboardVariant, + FONTS, +} from './types'; + +type ViewMode = 'cards' | 'list'; // Re-export MinerStats for backward compatibility export type { MinerStats } from './types'; const MINERS_PAGE_SIZE = 60; +const ELIGIBLE_QUERY_PARAM = 'eligible'; +const VIEW_QUERY_PARAM = 'view'; +const SEARCH_QUERY_PARAM = 'search'; +const VISIBLE_QUERY_PARAM = 'visible'; +const VIEW_STORAGE_KEY = 'leaderboard:viewMode'; +// Sort state (`sort`, `dir`) is owned by `useDataTableParams`. We reuse the +// `page` param slot for our "show more" count via the paramKeys override. + +const readStoredViewMode = (): ViewMode => { + try { + return window.localStorage.getItem(VIEW_STORAGE_KEY) === 'list' + ? 'list' + : 'cards'; + } catch { + return 'cards'; + } +}; + +const writeStoredViewMode = (mode: ViewMode) => { + try { + window.localStorage.setItem(VIEW_STORAGE_KEY, mode); + } catch { + // localStorage unavailable (private mode, quota) — preference won't persist + } +}; interface TopMinersTableProps { miners: MinerStats[]; isLoading?: boolean; - onSelectMiner: (githubId: string) => void; - activityLabel?: string; + getMinerHref: (miner: MinerStats) => string; + linkState?: Record; + variant?: LeaderboardVariant; + showDualEligibilityBadges?: boolean; } +const getAllowedSortOptions = (variant: LeaderboardVariant): SortOption[] => { + if (variant === 'discoveries') + return ['totalScore', 'usdPerDay', 'totalIssues', 'credibility']; + if (variant === 'watchlist') + return [ + 'totalScore', + 'usdPerDay', + 'totalPRs', + 'totalIssues', + 'credibility', + ]; + return ['totalScore', 'usdPerDay', 'totalPRs', 'credibility']; +}; + +type EligibilityFilter = 'all' | 'eligible' | 'ineligible'; + +const compareMiners = ( + a: MinerStats, + b: MinerStats, + option: SortOption, +): number => { + switch (option) { + case 'totalScore': + return a.totalScore - b.totalScore; + case 'usdPerDay': + return (a.usdPerDay ?? 0) - (b.usdPerDay ?? 0); + case 'totalPRs': + return a.totalPRs - b.totalPRs; + case 'totalIssues': + return (a.totalIssues ?? 0) - (b.totalIssues ?? 0); + case 'credibility': + return (a.credibility ?? 0) - (b.credibility ?? 0); + default: + return 0; + } +}; + const TopMinersTable: React.FC = ({ miners, isLoading, - onSelectMiner, - activityLabel = 'PRs', + getMinerHref, + linkState, + variant = 'oss', + showDualEligibilityBadges = false, }) => { - const [searchQuery, setSearchQuery] = useState(''); - const [sortOption, setSortOption] = useState('totalScore'); - const [showEligibleOnly, setShowEligibleOnly] = useState(false); - const [visibleCount, setVisibleCount] = useState(MINERS_PAGE_SIZE); - - // Helper to sort a list of miners - const sortMinersList = (list: MinerStats[], option: SortOption) => - [...list].sort((a, b) => { - switch (option) { - case 'totalScore': - return b.totalScore - a.totalScore; - case 'usdPerDay': - return (b.usdPerDay ?? 0) - (a.usdPerDay ?? 0); - case 'totalPRs': - return b.totalPRs - a.totalPRs; - case 'credibility': - return (b.credibility ?? 0) - (a.credibility ?? 0); - default: - return 0; + const allowedSortKeys = useMemo( + () => getAllowedSortOptions(variant), + [variant], + ); + + const theme = useTheme(); + const isSmUp = useMediaQuery(theme.breakpoints.up('sm')); + const [mobileSearchOpen, setMobileSearchOpen] = useState(false); + const mobileSearchInputRef = useRef(null); + + useEffect(() => { + if (isSmUp) setMobileSearchOpen(false); + }, [isSmUp]); + + // Stable filter configs — values are destructured below. + // `view` always writes the URL param (never returns null) — otherwise + // toggling back to 'cards' when the URL already has no `view` param + // produces a no-op `setSearchParams` call that doesn't trigger a + // re-render, so the UI stays stuck on the stale localStorage-sourced + // value until a manual refresh. Writing the param explicitly forces + // React to re-render through the URL change. + const filtersConfig = useMemo( + () => ({ + view: { + paramKey: VIEW_QUERY_PARAM, + parse: (raw: string | null): ViewMode => + raw === 'list' + ? 'list' + : raw === 'cards' + ? 'cards' + : readStoredViewMode(), + serialize: (value: ViewMode): string => value, + resetPageOnChange: false, + }, + eligible: { + paramKey: ELIGIBLE_QUERY_PARAM, + parse: (raw: string | null): EligibilityFilter => + raw === 'true' ? 'eligible' : raw === 'false' ? 'ineligible' : 'all', + serialize: (value: EligibilityFilter): string | null => + value === 'all' ? null : value === 'eligible' ? 'true' : 'false', + }, + search: { + paramKey: SEARCH_QUERY_PARAM, + parse: (raw: string | null): string => raw ?? '', + serialize: (value: string): string | null => value.trim() || null, + }, + }), + [], + ); + + const { + sortField: sortOption, + sortOrder: sortDirection, + setSort: handleSortChange, + page: storedVisibleCount, + setPage: setVisibleCount, + filters: { + view: viewMode, + eligible: eligibilityFilter, + search: searchQuery, + }, + setFilter, + } = useDataTableParams< + SortOption, + { view: ViewMode; eligible: EligibilityFilter; search: string } + >({ + sortKeys: allowedSortKeys, + defaultSortKey: 'totalScore', + // Reuse the hook's `page` slot for our "show more" count — setSort and + // filter changes reset it, which is the behavior we want. + paramKeys: { page: VISIBLE_QUERY_PARAM }, + filters: filtersConfig, + }); + + // `page` is 0 when no visible param is set — clamp to the initial batch. + const visibleCount = + storedVisibleCount < MINERS_PAGE_SIZE + ? MINERS_PAGE_SIZE + : storedVisibleCount; + + const handleViewModeChange = useCallback( + (nextMode: ViewMode) => { + // Persist the user's choice BEFORE updating the URL. When the serializer + // returns null ('cards' is the default), the URL param is dropped and + // the next render's parse falls back to localStorage — we need the new + // value to be stored by that point. + writeStoredViewMode(nextMode); + setFilter('view', nextMode); + }, + [setFilter], + ); + + const handleEligibilityChange = useCallback( + (nextFilter: EligibilityFilter) => setFilter('eligible', nextFilter), + [setFilter], + ); + + const handleSearchChange = useCallback( + (nextQuery: string) => { + if (!nextQuery.trim() && !isSmUp) { + setMobileSearchOpen(false); } - }); + setFilter('search', nextQuery); + }, + [isSmUp, setFilter], + ); + + const minersAfterEligibilityOnly = useMemo(() => { + if (eligibilityFilter === 'eligible') { + return miners.filter((m) => m.isEligible); + } + if (eligibilityFilter === 'ineligible') { + return miners.filter((m) => !m.isEligible); + } + return miners; + }, [miners, eligibilityFilter]); + + const handleMobileSearchBlur = useCallback(() => { + if (!isSmUp && !searchQuery.trim()) { + setMobileSearchOpen(false); + } + }, [isSmUp, searchQuery]); - // Process and filter miners const filteredMiners = useMemo(() => { - let result = [...miners]; + let result = miners; if (searchQuery) { const lowerQuery = searchQuery.toLowerCase(); @@ -60,19 +253,22 @@ const TopMinersTable: React.FC = ({ ); } - if (showEligibleOnly) { + if (eligibilityFilter === 'eligible') { result = result.filter((m) => m.isEligible); + } else if (eligibilityFilter === 'ineligible') { + result = result.filter((m) => !m.isEligible); } - return sortMinersList(result, sortOption).map((miner, index) => ({ - ...miner, - rank: index + 1, - })); - }, [miners, searchQuery, showEligibleOnly, sortOption]); + const directionMultiplier = sortDirection === 'asc' ? 1 : -1; + return [...result] + .sort((a, b) => compareMiners(a, b, sortOption) * directionMultiplier) + .map((miner, index) => ({ ...miner, rank: index + 1 })); + }, [miners, searchQuery, eligibilityFilter, sortOption, sortDirection]); useEffect(() => { - setVisibleCount(MINERS_PAGE_SIZE); - }, [miners, searchQuery, showEligibleOnly, sortOption]); + if (visibleCount <= filteredMiners.length) return; + setVisibleCount(0); + }, [filteredMiners.length, visibleCount, setVisibleCount]); const visibleMiners = useMemo( () => filteredMiners.slice(0, visibleCount), @@ -84,6 +280,13 @@ const TopMinersTable: React.FC = ({ filteredMiners.length - visibleMiners.length, ); + const searchActive = searchQuery.trim().length > 0; + const minersTitle = searchActive + ? `Miners (${filteredMiners.length}/${minersAfterEligibilityOnly.length})` + : `Miners (${filteredMiners.length})`; + + const showMobileSearchField = isSmUp || mobileSearchOpen || searchActive; + if (isLoading) { return ( @@ -93,33 +296,9 @@ const TopMinersTable: React.FC = ({ } return ( - - {/* Header Card */} + + {/* Header Card — two-row toolbar */} - - setShowEligibleOnly((prev) => !prev)} - /> - - } - action={} sx={{ mb: 2, position: 'sticky', @@ -133,30 +312,152 @@ const TopMinersTable: React.FC = ({ boxShadow: 'none', }} > - {null} + + {/* Row 1: Title + Search */} + + + {minersTitle} + + + {showMobileSearchField ? ( + + + + ) : ( + { + setMobileSearchOpen(true); + requestAnimationFrame(() => { + mobileSearchInputRef.current?.focus(); + }); + }} + sx={(t) => ({ + color: t.palette.text.secondary, + border: `1px solid ${t.palette.border.light}`, + borderRadius: 2, + p: 0.75, + })} + > + + + )} + + + + {/* Row 2: Sort tabs + Eligibility toggle */} + + + + + + + + - {filteredMiners.length > 0 && ( + {filteredMiners.length > 0 && viewMode === 'cards' && ( {visibleMiners.map((miner) => ( - + onSelectMiner(miner.githubId)} + variant={variant} + href={getMinerHref(miner)} + linkState={linkState} + showDualEligibilityBadges={showDualEligibilityBadges} /> ))} )} + {filteredMiners.length > 0 && viewMode === 'list' && ( + + )} + {remainingMiners > 0 && ( - setVisibleCount((prev) => - Math.min(prev + MINERS_PAGE_SIZE, filteredMiners.length), - ) - } + onClick={() => { + const nextVisibleCount = Math.min( + visibleCount + MINERS_PAGE_SIZE, + filteredMiners.length, + ); + // Pass 0 when at/below the initial batch → hook deletes the param. + setVisibleCount( + nextVisibleCount > MINERS_PAGE_SIZE ? nextVisibleCount : 0, + ); + }} sx={(theme) => ({ py: 1.25, borderRadius: 2, @@ -209,119 +510,248 @@ const TopMinersTable: React.FC = ({ interface SortButtonsProps { sortOption: SortOption; + sortDirection: SortOrder; onSortChange: (option: SortOption) => void; - activityLabel: string; + variant: LeaderboardVariant; } const SortButtons: React.FC = ({ sortOption, + sortDirection, onSortChange, - activityLabel, + variant, }) => ( {[ { label: 'Score', value: 'totalScore' }, { label: 'Earnings', value: 'usdPerDay' }, - { label: activityLabel, value: 'totalPRs' }, + ...(variant !== 'discoveries' + ? [{ label: 'PRs', value: 'totalPRs' as const }] + : []), + ...(variant === 'discoveries' || variant === 'watchlist' + ? [{ label: 'Issues', value: 'totalIssues' as const }] + : []), { label: 'Credibility', value: 'credibility' }, - ].map((option) => ( - onSortChange(option.value as SortOption)} - sx={(theme) => ({ - px: 1.5, - height: 32, - display: 'flex', - alignItems: 'center', - borderRadius: 2, - cursor: 'pointer', - backgroundColor: - sortOption === option.value + ].map((option) => { + const isActive = sortOption === option.value; + return ( + onSortChange(option.value as SortOption)} + sx={(theme) => ({ + px: 1.5, + py: { xs: 0.75, sm: 0 }, + minHeight: { xs: 40, sm: 32 }, + height: { xs: 'auto', sm: 32 }, + display: 'flex', + alignItems: 'center', + gap: 0.5, + flexShrink: 0, + borderRadius: 2, + cursor: 'pointer', + backgroundColor: isActive ? alpha(theme.palette.text.primary, 0.1) : 'transparent', - color: - sortOption === option.value - ? theme.palette.text.primary - : STATUS_COLORS.open, - border: '1px solid', - borderColor: - sortOption === option.value - ? theme.palette.border.medium - : 'transparent', - transition: 'all 0.2s', - '&:hover': { - backgroundColor: theme.palette.surface.light, - color: theme.palette.text.primary, - }, - })} - > - - {option.label} - - - ))} + + {option.label} + + {isActive && ( + + {sortDirection === 'asc' ? '▲' : '▼'} + + )} + + ); + })} ); -interface FilterButtonProps { - label: string; - isActive: boolean; - onClick: () => void; +interface ViewModeToggleProps { + viewMode: ViewMode; + onChange: (mode: ViewMode) => void; +} + +const ViewModeToggle: React.FC = ({ + viewMode, + onChange, +}) => { + const options: { + value: ViewMode; + label: string; + Icon: typeof ViewListIcon; + }[] = [ + { value: 'cards', label: 'Card view', Icon: ViewModuleIcon }, + { value: 'list', label: 'List view', Icon: ViewListIcon }, + ]; + + return ( + ({ + display: 'inline-flex', + height: 32, + borderRadius: 2, + border: '1px solid', + borderColor: theme.palette.border.light, + backgroundColor: theme.palette.surface.subtle, + overflow: 'hidden', + })} + role="group" + aria-label="Toggle view mode" + > + {options.map(({ value, label, Icon }) => { + const isActive = viewMode === value; + return ( + + onChange(value)} + aria-label={label} + aria-pressed={isActive} + sx={(theme) => ({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 36, + height: '100%', + border: 'none', + outline: 'none', + cursor: 'pointer', + backgroundColor: isActive + ? alpha(theme.palette.text.primary, 0.1) + : 'transparent', + color: isActive + ? theme.palette.text.primary + : STATUS_COLORS.open, + transition: 'all 0.2s', + '&:hover': { + backgroundColor: theme.palette.surface.light, + color: theme.palette.text.primary, + }, + '&:focus-visible': { + outline: `2px solid ${theme.palette.status.info}`, + outlineOffset: -2, + }, + })} + > + + + + ); + })} + + ); +}; + +interface EligibilityToggleProps { + value: EligibilityFilter; + onChange: (next: EligibilityFilter) => void; } -const FilterButton: React.FC = ({ - label, - isActive, - onClick, +const ELIGIBILITY_OPTIONS: Array<{ value: EligibilityFilter; label: string }> = + [ + { value: 'all', label: 'All' }, + { value: 'eligible', label: 'Eligible' }, + { value: 'ineligible', label: 'Ineligible' }, + ]; + +const EligibilityToggle: React.FC = ({ + value, + onChange, }) => ( ({ - px: 1.5, - height: 32, display: 'flex', - alignItems: 'center', + flexWrap: { xs: 'wrap', sm: 'nowrap' }, + justifyContent: 'center', + gap: 0.5, + p: 0.5, borderRadius: 2, - cursor: 'pointer', - backgroundColor: isActive - ? alpha(theme.palette.status.merged, 0.16) - : 'transparent', - color: isActive ? theme.palette.text.primary : STATUS_COLORS.open, - border: '1px solid', - borderColor: isActive - ? alpha(theme.palette.status.merged, 0.4) - : 'transparent', - transition: 'all 0.2s', - '&:hover': { - backgroundColor: isActive - ? alpha(theme.palette.status.merged, 0.2) - : theme.palette.surface.light, - color: theme.palette.text.primary, - }, + backgroundColor: theme.palette.surface.light, + width: { xs: '100%', sm: 'auto' }, + maxWidth: { xs: 420, sm: 'none' }, })} > - - {label} - + {ELIGIBILITY_OPTIONS.map((option) => { + const isActive = value === option.value; + return ( + onChange(option.value)} + sx={(theme) => ({ + px: 1.5, + minHeight: { xs: 36, sm: 24 }, + height: { xs: 'auto', sm: 24 }, + flex: { xs: '1 1 auto', sm: '0 0 auto' }, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + border: 0, + borderRadius: 1.5, + backgroundColor: isActive + ? alpha(theme.palette.text.primary, 0.15) + : 'transparent', + color: isActive + ? theme.palette.text.primary + : theme.palette.text.tertiary, + cursor: 'pointer', + fontFamily: FONTS.mono, + fontSize: '0.72rem', + fontWeight: isActive ? 600 : 500, + lineHeight: 1, + transition: 'all 0.2s ease', + '&:hover': { + backgroundColor: alpha(theme.palette.text.primary, 0.1), + color: theme.palette.text.primary, + }, + '&:focus-visible': { + outline: `1px solid ${theme.palette.border.medium}`, + outlineOffset: 1, + }, + })} + > + {option.label} + + ); + })} ); diff --git a/src/components/leaderboard/TopPRsTable.tsx b/src/components/leaderboard/TopPRsTable.tsx deleted file mode 100644 index 0016f437..00000000 --- a/src/components/leaderboard/TopPRsTable.tsx +++ /dev/null @@ -1,1005 +0,0 @@ -import React, { useState, useMemo, useRef, useEffect } from 'react'; -import { - Box, - Card, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Typography, - CircularProgress, - Avatar, - TextField, - InputAdornment, - Tooltip, - IconButton, - Collapse, - TablePagination, - Select, - MenuItem, - FormControl, - Button, - Stack, - Chip, - Switch, - FormControlLabel, - alpha, -} from '@mui/material'; -import SearchIcon from '@mui/icons-material/Search'; -import FilterListIcon from '@mui/icons-material/FilterList'; -import BarChartIcon from '@mui/icons-material/BarChart'; -import TableChartIcon from '@mui/icons-material/TableChart'; -import ReactECharts from 'echarts-for-react'; -import { type CommitLog } from '../../api/models/Dashboard'; -import { formatUsdEstimate, truncateText } from '../../utils'; -import { RankIcon } from './RankIcon'; -import theme, { STATUS_COLORS } from '../../theme'; - -interface TopPRsTableProps { - prs: CommitLog[]; - isLoading?: boolean; - onSelectPR: (repository: string, pullRequestNumber: number) => void; - onSelectMiner: (githubId: string) => void; - onSelectRepository: (repositoryFullName: string) => void; -} - -const TopPRsTable: React.FC = ({ - prs, - isLoading, - onSelectPR, - onSelectMiner, - onSelectRepository, -}) => { - const [searchQuery, setSearchQuery] = useState(''); - const [showFilters, setShowFilters] = useState(false); - const [showChart, setShowChart] = useState(false); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(10); - const [statusFilter, setStatusFilter] = useState< - 'all' | 'open' | 'closed' | 'merged' - >('all'); - const [useLogScale, setUseLogScale] = useState(true); - const cardRef = useRef(null); - - const rankedPRs = useMemo( - () => prs.map((pr, index) => ({ ...pr, rank: index + 1 })), - [prs], - ); - - const filteredPRs = useMemo(() => { - let filtered = rankedPRs; - - // Apply status filter - if (statusFilter !== 'all') { - if (statusFilter === 'merged') { - filtered = filtered.filter( - (pr) => pr.prState === 'MERGED' || !!pr.mergedAt, - ); - } else if (statusFilter === 'open') { - filtered = filtered.filter( - (pr) => pr.prState === 'OPEN' || (!pr.prState && !pr.mergedAt), - ); - } else if (statusFilter === 'closed') { - filtered = filtered.filter( - (pr) => pr.prState === 'CLOSED' && !pr.mergedAt, - ); - } - } - - // Apply search filter - if (searchQuery) { - const lowerQuery = searchQuery.toLowerCase(); - filtered = filtered.filter( - (pr) => - pr.pullRequestTitle?.toLowerCase().includes(lowerQuery) || - pr.author?.toLowerCase().includes(lowerQuery) || - pr.repository?.toLowerCase().includes(lowerQuery), - ); - } - - return filtered; - }, [rankedPRs, searchQuery, statusFilter]); - - const statusCounts = useMemo( - () => ({ - all: rankedPRs.length, - open: rankedPRs.filter( - (pr) => pr.prState === 'OPEN' || (!pr.prState && !pr.mergedAt), - ).length, - merged: rankedPRs.filter((pr) => pr.prState === 'MERGED' || !!pr.mergedAt) - .length, - closed: rankedPRs.filter((pr) => pr.prState === 'CLOSED' && !pr.mergedAt) - .length, - }), - [rankedPRs], - ); - - const FilterButton = ({ - label, - value, - count, - color, - }: { - label: string; - value: typeof statusFilter; - count?: number; - color: string; - }) => ( - - ); - - const getChartOption = () => { - const chartData = filteredPRs.slice(0, 50); - const textColor = 'rgba(255, 255, 255, 0.85)'; - const gridColor = 'rgba(255, 255, 255, 0.08)'; - - const chartColor = theme.palette.primary.main; - - const xAxisData = chartData.map( - (item) => `#${item?.pullRequestNumber || ''}`, - ); - - const stemData = chartData.map((item) => ({ - value: Number(parseFloat(item?.score || '0')), - title: item?.pullRequestTitle || '', - author: item?.author || '', - repository: item?.repository || '', - prNumber: item?.pullRequestNumber || 0, - rank: item?.rank || 0, - })); - - const dotData = stemData.map((item) => ({ - value: item.value, - title: item.title, - author: item.author, - repository: item.repository, - prNumber: item.prNumber, - rank: item.rank, - itemStyle: { - color: chartColor, - shadowBlur: 10, - shadowColor: chartColor, - }, - })); - - return { - backgroundColor: 'transparent', - title: { - text: 'Pull Request Performance Ranking', - subtext: 'Individual PR scores ranked by performance', - left: 'center', - top: 20, - textStyle: { - color: '#ffffff', - fontFamily: 'JetBrains Mono', - fontSize: 18, - fontWeight: 600, - }, - subtextStyle: { - color: 'rgba(255, 255, 255, 0.6)', - fontFamily: 'JetBrains Mono', - fontSize: 11, - }, - }, - tooltip: { - trigger: 'axis', - axisPointer: { - type: 'shadow', - shadowStyle: { - color: 'rgba(255, 255, 255, 0.05)', - }, - }, - backgroundColor: 'rgba(10, 10, 12, 0.98)', - borderColor: 'rgba(255, 255, 255, 0.2)', - borderWidth: 1, - textStyle: { - color: '#fff', - fontFamily: 'JetBrains Mono', - fontSize: 12, - }, - padding: [14, 18], - formatter: (params: any) => { - const data = params[0]?.data || params[1]?.data; - if (!data) return ''; - - return ` -
-
- PR #${data.prNumber} -
-
- ${data.title} -
-
-
- Rank: - #${data.rank} -
-
- Score: - ${data.value.toFixed(4)} -
-
- Author: - ${data.author} -
-
- Repository: - ${data.repository.split('/')[1] || data.repository} -
-
-
- `; - }, - }, - grid: { - left: '3%', - right: '3%', - bottom: '18%', - top: '18%', - containLabel: true, - }, - dataZoom: [ - { - type: 'inside', - start: 0, - end: 100, - zoomOnMouseWheel: true, - moveOnMouseMove: true, - }, - ], - xAxis: { - type: 'category', - data: xAxisData, - axisLabel: { - color: textColor, - fontFamily: 'JetBrains Mono', - fontSize: 11, - interval: 0, - rotate: 45, - margin: 12, - }, - axisLine: { - lineStyle: { - color: gridColor, - width: 1, - }, - }, - axisTick: { - show: false, - }, - }, - yAxis: { - type: useLogScale ? 'log' : 'value', - min: useLogScale ? 1 : 0, - logBase: 10, - name: 'PR Score', - nameTextStyle: { - color: textColor, - fontFamily: 'JetBrains Mono', - fontSize: 12, - }, - axisLabel: { - color: textColor, - fontFamily: 'JetBrains Mono', - fontSize: 11, - formatter: (value: number) => { - // For small values in log scale, format appropriately - if (value < 0.01) return value.toExponential(1); - return value.toFixed(2); - }, - }, - splitLine: { - lineStyle: { - color: gridColor, - type: 'dashed', - opacity: 0.5, - }, - }, - axisLine: { - show: false, - }, - axisTick: { - show: false, - }, - }, - series: [ - { - name: 'Stems', - type: 'bar', - data: dotData.map((item) => ({ - ...item, - itemStyle: { - color: chartColor, - opacity: 0.4, - borderRadius: [2, 2, 0, 0], - }, - })), - barWidth: 2, - z: 1, - animationDuration: 1000, - animationEasing: 'cubicOut', - animationDelay: (idx: number) => idx * 30, - }, - { - name: 'Dots', - type: 'scatter', - data: dotData, - symbolSize: 14, - z: 2, - emphasis: { - scale: 1.5, - itemStyle: { - shadowBlur: 20, - borderColor: '#fff', - borderWidth: 2, - }, - }, - animationDuration: 1000, - animationEasing: 'cubicOut', - animationDelay: (idx: number) => idx * 30 + 100, - }, - ], - }; - }; - - const handleChangePage = (_event: unknown, newPage: number) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = ( - event: React.ChangeEvent, - ) => { - setRowsPerPage(parseInt(event.target.value, 10)); - setPage(0); - }; - - useEffect(() => { - setPage(0); - }, [searchQuery]); - - useEffect(() => { - if (cardRef.current) { - cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); - } - }, [rowsPerPage]); - - if (isLoading) { - return ( - - - - ); - } - - return ( - - - - Top Pull Requests{' '} - - ({filteredPRs.length}) - - - - - - setShowFilters(!showFilters)} - size="small" - sx={{ - color: showFilters ? '#ffffff' : 'rgba(255, 255, 255, 0.5)', - border: '1px solid rgba(255, 255, 255, 0.1)', - borderRadius: 2, - padding: '6px', - '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.05)', - borderColor: 'rgba(255, 255, 255, 0.2)', - }, - }} - > - - - - - - setShowChart(!showChart)} - size="small" - sx={{ - color: showChart ? '#ffffff' : 'rgba(255, 255, 255, 0.5)', - border: '1px solid rgba(255, 255, 255, 0.1)', - borderRadius: 2, - padding: '6px', - '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.05)', - borderColor: 'rgba(255, 255, 255, 0.2)', - }, - }} - > - {showChart ? ( - - ) : ( - - )} - - - - {showChart && ( - setUseLogScale(e.target.checked)} - size="small" - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { - color: '#primary.main', - }, - '& .MuiSwitch-track': { - backgroundColor: 'rgba(255, 255, 255, 0.3)', - }, - }} - /> - } - label={ - - Log Scale - - } - /> - )} - - - - - Rows: - - - - - - setSearchQuery(e.target.value)} - InputProps={{ - startAdornment: ( - - - - ), - }} - sx={{ - width: '200px', - '& .MuiOutlinedInput-root': { - color: '#ffffff', - fontFamily: '"JetBrains Mono", monospace', - backgroundColor: 'rgba(0, 0, 0, 0.4)', - fontSize: '0.8rem', - height: '36px', - borderRadius: 2, - '& fieldset': { borderColor: 'rgba(255, 255, 255, 0.1)' }, - '&:hover fieldset': { borderColor: 'rgba(255, 255, 255, 0.2)' }, - '&.Mui-focused fieldset': { borderColor: 'primary.main' }, - }, - }} - /> - - - - - - - - STATUS - - - - - - - - - - - - - - {showChart && filteredPRs.length > 0 && ( - - )} - - - - - - - - - Rank - - - Pull Request - - - Author - - - Repository - - - Status - - - Score - - - - - {filteredPRs - .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) - .map((pr) => ( - - onSelectPR(pr.repository || '', pr.pullRequestNumber) - } - sx={{ - cursor: 'pointer', - '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.05)', - }, - transition: 'all 0.2s', - }} - > - - - - - - - {truncateText(pr.pullRequestTitle || '', 50)} - - - - - { - e.stopPropagation(); - onSelectMiner(pr.githubId || pr.author || ''); - }} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1, - cursor: 'pointer', - '&:hover': { - '& .MuiTypography-root': { - color: 'primary.main', - textDecoration: 'underline', - }, - }, - }} - > - - - - {truncateText(pr.author || '', 20)} - - - - - - { - e.stopPropagation(); - onSelectRepository(pr.repository || ''); - }} - sx={{ - display: 'flex', - alignItems: 'center', - gap: 1.5, - cursor: 'pointer', - '&:hover': { - '& .MuiTypography-root': { - color: 'primary.main', - textDecoration: 'underline', - }, - }, - }} - > - - - - {truncateText(pr.repository || '', 30)} - - - - - - {(() => { - const state = - pr.prState?.toUpperCase() || - (pr.mergedAt ? 'MERGED' : 'OPEN'); - let color = theme.palette.status.neutral; - const label = state; - - if (state === 'MERGED') { - color = theme.palette.status.merged; - } else if (state === 'OPEN') { - color = theme.palette.status.open; - } else if (state === 'CLOSED') { - color = theme.palette.status.closed; - } - - return ( - - ); - })()} - - - - - {parseFloat(pr.score || '0').toFixed(4)} - - {(pr.prState === 'MERGED' || pr.mergedAt) && - formatUsdEstimate(pr.predictedUsdPerDay, { - includeApproxPrefix: true, - }) && ( - - - {formatUsdEstimate(pr.predictedUsdPerDay, { - includeApproxPrefix: true, - })} - /d - - - )} - - - - ))} - -
-
- -
- ); -}; - -const headerCellStyle = { - backgroundColor: 'rgba(18, 18, 20, 0.95)', - backdropFilter: 'blur(8px)', - color: '#ffffff', - fontFamily: '"JetBrains Mono", monospace', - fontWeight: 500, - fontSize: '0.75rem', - borderBottom: '1px solid rgba(255, 255, 255, 0.1)', - height: '48px', - py: 1, - boxSizing: 'border-box' as const, -}; - -const bodyCellStyle = { - color: '#ffffff', - fontFamily: '"JetBrains Mono", monospace', - borderBottom: '1px solid rgba(255, 255, 255, 0.1)', - fontSize: '0.75rem', - py: 0.75, - height: '52px', - boxSizing: 'border-box' as const, -}; - -export default TopPRsTable; diff --git a/src/components/leaderboard/TopRepositoriesTable.tsx b/src/components/leaderboard/TopRepositoriesTable.tsx index 2fcf3c2b..4a8a0665 100644 --- a/src/components/leaderboard/TopRepositoriesTable.tsx +++ b/src/components/leaderboard/TopRepositoriesTable.tsx @@ -8,6 +8,8 @@ import React, { import { Box, Card, + Grid, + Skeleton, Table, TableBody, TableCell, @@ -15,7 +17,6 @@ import { TableHead, TableRow, Typography, - CircularProgress, Avatar, TextField, InputAdornment, @@ -29,26 +30,55 @@ import { Button, Switch, FormControlLabel, + CircularProgress, + alpha, + useMediaQuery, + useTheme, type SxProps, type Theme, } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import BarChartIcon from '@mui/icons-material/BarChart'; import TableChartIcon from '@mui/icons-material/TableChart'; +import ViewModuleIcon from '@mui/icons-material/ViewModule'; +import ViewListIcon from '@mui/icons-material/ViewList'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import FilterButton from '../FilterButton'; +import { RepositoryCard } from './RepositoryCard'; +import { + REPOSITORIES_CARD_ROWS, + REPOSITORIES_DEFAULT_CARD_ROWS, + REPOSITORIES_DEFAULT_LIST_ROWS, + REPOSITORIES_LIST_ROWS, + REPOSITORIES_VALID_ROWS, + REPOSITORIES_VIEW_QUERY_PARAM, + clampRowsForRepositoriesView, + getRepositoriesViewModeFromQuery, + readStoredRepositoriesViewMode, + writeStoredRepositoriesViewMode, + type RepositoriesViewMode, +} from './repositoriesViewMode'; import ReactECharts from 'echarts-for-react'; -import { useSearchParams } from 'react-router-dom'; +import type { TooltipComponentFormatterCallbackParams } from 'echarts'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import { LinkTableRow } from '../common/linkBehavior'; import { truncateText } from '../../utils'; import { RankIcon } from './RankIcon'; - -interface RepoStats { - repository: string; - totalScore: number; - totalPRs: number; - uniqueMiners: Set; - weight: number; - rank?: number; - inactiveAt?: string | null; -} +import LeaderboardTableSkeleton from './LeaderboardTableSkeleton'; +import { + getRepositoryOwnerAvatarBackground, + headerCellStyle, + bodyCellStyle, + type RepoStats, +} from './types'; +import { + CHART_COLORS, + STATUS_COLORS, + TEXT_OPACITY, + UI_COLORS, + scrollbarSx, +} from '../../theme'; type SortColumn = | 'rank' @@ -58,11 +88,21 @@ type SortColumn = | 'totalPRs' | 'contributors'; type SortDirection = 'asc' | 'desc'; +type ViewMode = RepositoriesViewMode; + +const CARD_SORT_OPTIONS: Array<{ value: SortColumn; label: string }> = [ + { value: 'weight', label: 'Weight' }, + { value: 'totalScore', label: 'Total Score' }, + { value: 'totalPRs', label: 'PRs' }, + { value: 'contributors', label: 'Contributors' }, + { value: 'repository', label: 'Repository' }, +]; interface TopRepositoriesTableProps { repositories: RepoStats[]; isLoading?: boolean; - onSelectRepository: (repositoryFullName: string) => void; + getRepositoryHref: (repositoryFullName: string) => string; + linkState?: Record; } const VALID_SORT_COLUMNS: SortColumn[] = [ @@ -73,28 +113,49 @@ const VALID_SORT_COLUMNS: SortColumn[] = [ 'totalPRs', 'contributors', ]; -const VALID_ROWS = [10, 25, 50]; const TopRepositoriesTable: React.FC = ({ repositories, isLoading, - onSelectRepository, + getRepositoryHref, + linkState, }) => { + const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); // Read initial state from URL params, falling back to defaults - const urlRows = parseInt(searchParams.get('rows') || '', 10); - const urlPage = parseInt(searchParams.get('page') || '', 10); + const urlRows = parseInt(searchParams.get('rows') || '0', 10); + const urlPage = parseInt(searchParams.get('page') || '0', 10); const urlSort = searchParams.get('sort') as SortColumn; const urlDir = searchParams.get('dir') as SortDirection; const urlSearch = searchParams.get('search') || ''; + const urlStatusFilter = searchParams.get('status') as + | 'all' + | 'active' + | 'inactive' + | null; const [searchQuery, setSearchQuery] = useState(urlSearch); + const [statusFilter, setStatusFilter] = useState< + 'all' | 'active' | 'inactive' + >( + urlStatusFilter === 'active' || urlStatusFilter === 'inactive' + ? urlStatusFilter + : 'all', + ); const [showChart, setShowChart] = useState(false); const [page, setPage] = useState(urlPage >= 0 ? urlPage : 0); - const [rowsPerPage, setRowsPerPage] = useState( - VALID_ROWS.includes(urlRows) ? urlRows : 10, - ); + const [rowsPerPage, setRowsPerPage] = useState(() => { + const initialView = getRepositoriesViewModeFromQuery( + searchParams.get(REPOSITORIES_VIEW_QUERY_PARAM), + readStoredRepositoriesViewMode(), + ); + return REPOSITORIES_VALID_ROWS.includes(urlRows) + ? clampRowsForRepositoriesView(urlRows, initialView) + : initialView === 'cards' + ? REPOSITORIES_DEFAULT_CARD_ROWS + : REPOSITORIES_DEFAULT_LIST_ROWS; + }); const [sortColumn, setSortColumn] = useState( urlSort && VALID_SORT_COLUMNS.includes(urlSort) ? urlSort : 'weight', ); @@ -102,8 +163,24 @@ const TopRepositoriesTable: React.FC = ({ urlDir === 'asc' || urlDir === 'desc' ? urlDir : 'desc', ); const [useLogScale, setUseLogScale] = useState(true); + const [isMobileSearchOpen, setIsMobileSearchOpen] = useState(false); + const [storedViewMode, setStoredViewMode] = useState( + readStoredRepositoriesViewMode, + ); + const viewMode = useMemo( + () => + getRepositoriesViewModeFromQuery( + searchParams.get(REPOSITORIES_VIEW_QUERY_PARAM), + storedViewMode, + ), + [searchParams, storedViewMode], + ); const isInitialMount = useRef(true); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); const trimmedSearch = searchQuery.trim(); + const isMobileSearchVisible = + isMobile && (isMobileSearchOpen || !!trimmedSearch); const isDirectRepoInput = /^[^/\s]+\/[^/\s]+$/.test(trimmedSearch); // Sync filter state to URL params (replace, don't push) @@ -115,12 +192,16 @@ const TopRepositoriesTable: React.FC = ({ const sort = overrides?.sort ?? sortColumn; const dir = overrides?.dir ?? sortDirection; const search = overrides?.search ?? searchQuery; + const active = overrides?.status ?? statusFilter; + const view = overrides?.view ?? viewMode; if (rows !== '10') params.rows = rows; if (pg !== '0') params.page = pg; if (sort !== 'weight') params.sort = sort; if (dir !== 'desc') params.dir = dir; if (search) params.search = search; + if (active !== 'all') params.status = active; + if (view === 'cards') params.view = view; setSearchParams(params, { replace: true }); }, @@ -130,10 +211,32 @@ const TopRepositoriesTable: React.FC = ({ sortColumn, sortDirection, searchQuery, + statusFilter, + viewMode, setSearchParams, ], ); + const handleViewModeChange = useCallback( + (nextMode: ViewMode) => { + writeStoredRepositoriesViewMode(nextMode); + setStoredViewMode(nextMode); + const nextRows = clampRowsForRepositoriesView(rowsPerPage, nextMode); + if (nextRows !== rowsPerPage) { + setRowsPerPage(nextRows); + setPage(0); + syncToUrl({ + view: nextMode, + rows: String(nextRows), + page: '0', + }); + } else { + syncToUrl({ view: nextMode }); + } + }, + [rowsPerPage, syncToUrl], + ); + const rankedRepositories = useMemo(() => { // First, sort by the current sort column const sorted = [...repositories].sort((a, b) => { @@ -170,6 +273,12 @@ const TopRepositoriesTable: React.FC = ({ const filteredRepositories = useMemo(() => { let filtered = rankedRepositories; + if (statusFilter === 'active') { + filtered = filtered.filter((repo) => !repo.inactiveAt); + } else if (statusFilter === 'inactive') { + filtered = filtered.filter((repo) => !!repo.inactiveAt); + } + // Apply search filter if (searchQuery) { const lowerQuery = searchQuery.toLowerCase(); @@ -179,12 +288,79 @@ const TopRepositoriesTable: React.FC = ({ } return filtered; - }, [rankedRepositories, searchQuery]); + }, [rankedRepositories, statusFilter, searchQuery]); + + const maxWeight = useMemo( + () => rankedRepositories.reduce((m, r) => (r.weight > m ? r.weight : m), 0), + [rankedRepositories], + ); + + const pagedRepositories = useMemo( + () => + filteredRepositories.slice( + page * rowsPerPage, + page * rowsPerPage + rowsPerPage, + ), + [filteredRepositories, page, rowsPerPage], + ); const getChartOption = () => { - const chartData = filteredRepositories.slice(0, 50); // Limit for performance - const textColor = 'rgba(255, 255, 255, 0.85)'; - const gridColor = 'rgba(255, 255, 255, 0.08)'; + const chartData = pagedRepositories; + const white = UI_COLORS.white; + const borderSubtle = alpha(white, 0.08); + const borderLight = alpha(white, 0.1); + const surfaceSubtle = alpha(white, 0.02); + const textColor = alpha(white, 0.85); + const gridColor = borderSubtle; + const tooltipBorderColor = borderLight; + const tooltipLabelColor = alpha(white, TEXT_OPACITY.secondary); + const primaryColor = UI_COLORS.white; + + const chartMetric: Record< + SortColumn, + { + title: string; + yAxis: string; + value: (r: (typeof chartData)[number]) => number; + } + > = { + weight: { + title: 'Repository Weights', + yAxis: 'Weight', + value: (r) => r.weight || 0, + }, + totalScore: { + title: 'Total Score', + yAxis: 'Total Score', + value: (r) => r.totalScore || 0, + }, + totalPRs: { + title: 'Pull Requests by Repository', + yAxis: 'PRs', + value: (r) => r.totalPRs || 0, + }, + contributors: { + title: 'Contributors by Repository', + yAxis: 'Contributors', + value: (r) => r.uniqueMiners?.size || 0, + }, + rank: { + title: 'Total Score', + yAxis: 'Total Score', + value: (r) => r.totalScore || 0, + }, + repository: { + title: 'Total Score', + yAxis: 'Total Score', + value: (r) => r.totalScore || 0, + }, + }; + const metric = chartMetric[sortColumn] ?? chartMetric.totalScore; + const effectiveLogScale = + useLogScale && + sortColumn !== 'weight' && + sortColumn !== 'totalPRs' && + sortColumn !== 'contributors'; const barGradient = { type: 'linear', @@ -193,9 +369,9 @@ const TopRepositoriesTable: React.FC = ({ x2: 0, y2: 1, colorStops: [ - { offset: 0, color: 'rgba(139, 148, 158, 0.8)' }, - { offset: 0.5, color: 'rgba(139, 148, 158, 0.6)' }, - { offset: 1, color: 'rgba(100, 108, 118, 0.4)' }, + { offset: 0, color: alpha(CHART_COLORS.open, 0.8) }, + { offset: 0.5, color: alpha(CHART_COLORS.open, 0.6) }, + { offset: 1, color: alpha(CHART_COLORS.open, 0.4) }, ], }; @@ -205,7 +381,7 @@ const TopRepositoriesTable: React.FC = ({ })); const seriesData = chartData.map((item, index) => ({ - value: Number(item?.totalScore) || 0, + value: metric.value(item), rank: item?.rank || index + 1, repository: item?.repository || '', weight: item?.weight || 0, @@ -214,7 +390,7 @@ const TopRepositoriesTable: React.FC = ({ itemStyle: { color: barGradient, borderRadius: [6, 6, 0, 0], - shadowColor: 'rgba(100, 100, 100, 0.2)', + shadowColor: alpha(CHART_COLORS.open, 0.2), shadowBlur: 12, }, })); @@ -222,19 +398,17 @@ const TopRepositoriesTable: React.FC = ({ return { backgroundColor: 'transparent', title: { - text: 'Repository Score Performance', - subtext: 'Total score generated by repository contributions', + text: metric.title, + subtext: 'Values match the current table sort and page', left: 'center', top: 20, textStyle: { - color: '#ffffff', - fontFamily: 'JetBrains Mono', + color: primaryColor, fontSize: 18, fontWeight: 600, }, subtextStyle: { - color: 'rgba(255, 255, 255, 0.5)', - fontFamily: 'JetBrains Mono', + color: alpha(white, TEXT_OPACITY.tertiary), fontSize: 12, }, }, @@ -243,19 +417,19 @@ const TopRepositoriesTable: React.FC = ({ axisPointer: { type: 'shadow', shadowStyle: { - color: 'rgba(255, 255, 255, 0.05)', + color: borderSubtle, }, }, - backgroundColor: 'rgba(15, 15, 18, 0.95)', - borderColor: 'rgba(255, 255, 255, 0.15)', + backgroundColor: UI_COLORS.surfaceTooltip, + borderColor: alpha(white, 0.15), borderWidth: 1, textStyle: { - color: '#fff', - fontFamily: 'JetBrains Mono', + color: primaryColor, fontSize: 12, }, padding: [12, 16], - formatter: (params: any) => { + formatter: (params: TooltipComponentFormatterCallbackParams) => { + if (!Array.isArray(params)) return ''; const data = params[0]; const item = seriesData[data.dataIndex]; @@ -264,11 +438,11 @@ const TopRepositoriesTable: React.FC = ({
#${item.rank} ${item.repository}
-
-
Total Score: ${item.value.toFixed(2)}
-
Weight: ${item.weight.toFixed(2)}
-
Pull Requests: ${item.prs}
-
Contributors: ${item.contributors}
+
+
Total Score: ${item.value.toFixed(2)}
+
Weight: ${item.weight.toFixed(2)}
+
Pull Requests: ${item.prs}
+
Contributors: ${item.contributors}
`; @@ -295,7 +469,6 @@ const TopRepositoriesTable: React.FC = ({ data: xAxisData.map((item) => item.name), axisLabel: { color: textColor, - fontFamily: 'JetBrains Mono', fontSize: 11, interval: 0, rotate: 45, @@ -314,22 +487,21 @@ const TopRepositoriesTable: React.FC = ({ }, }, yAxis: { - type: useLogScale ? 'log' : 'value', - min: useLogScale ? 1 : 0, + type: effectiveLogScale ? 'log' : 'value', + min: effectiveLogScale ? 1 : 0, logBase: 10, - name: 'Total Score', + name: metric.yAxis, nameTextStyle: { color: textColor, - fontFamily: 'JetBrains Mono', fontSize: 12, padding: [0, 0, 0, 0], }, axisLabel: { color: textColor, - fontFamily: 'JetBrains Mono', fontSize: 11, formatter: (value: number) => { if (value >= 1000) return `${(value / 1000).toFixed(1)}k`; + if (sortColumn === 'weight') return value.toFixed(2); return value.toFixed(0); }, }, @@ -354,14 +526,14 @@ const TopRepositoriesTable: React.FC = ({ barWidth: '60%', showBackground: true, backgroundStyle: { - color: 'rgba(255, 255, 255, 0.02)', + color: surfaceSubtle, borderRadius: [6, 6, 0, 0], }, emphasis: { focus: 'series', itemStyle: { shadowBlur: 20, - shadowColor: 'rgba(88, 166, 255, 0.5)', + shadowColor: alpha(STATUS_COLORS.info, 0.5), }, }, animationDuration: 1000, @@ -399,6 +571,72 @@ const TopRepositoriesTable: React.FC = ({ syncToUrl({ sort: column, dir: newDir, page: '0' }); }; + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && isDirectRepoInput) { + navigate(getRepositoryHref(trimmedSearch), { + state: linkState, + }); + } + if (e.key === 'Escape' && !trimmedSearch) { + setIsMobileSearchOpen(false); + } + }; + + const searchAdornment = ( + + + + ); + + const searchFieldBaseSx = { + '& .MuiOutlinedInput-root': { + color: 'text.primary', + backgroundColor: 'background.default', + fontSize: '0.8rem', + height: '36px', + borderRadius: 2, + '& fieldset': { borderColor: 'border.light' }, + '&:hover fieldset': { + borderColor: 'border.medium', + }, + '&.Mui-focused fieldset': { borderColor: 'primary.main' }, + }, + } as const; + + const searchInput = ( + setSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + onBlur={() => { + if (isMobile && !trimmedSearch) { + setIsMobileSearchOpen(false); + } + }} + autoFocus={isMobileSearchOpen} + InputProps={{ + startAdornment: searchAdornment, + }} + sx={{ + width: '200px', + ...(isMobileSearchVisible + ? { + flexBasis: { xs: '100%', sm: 'auto' }, + order: { xs: 10, sm: 'initial' }, + } + : {}), + ...searchFieldBaseSx, + }} + /> + ); + const SortableHeader = ({ column, children, @@ -418,7 +656,7 @@ const TopRepositoriesTable: React.FC = ({ cursor: 'pointer', userSelect: 'none', '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.05)', + backgroundColor: 'surface.light', }, }} onClick={() => handleSort(column)} @@ -453,6 +691,12 @@ const TopRepositoriesTable: React.FC = ({ syncToUrl({ search: searchQuery, page: '0' }); }, [searchQuery]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (!isMobile) { + setIsMobileSearchOpen(false); + } + }, [isMobile]); + if (isLoading) { return ( @@ -465,7 +709,8 @@ const TopRepositoriesTable: React.FC = ({ = ({ > - {/* Row 2: All Controls */} + {/* Row 1: All Controls */} - - - setShowChart(!showChart)} - size="small" - sx={{ - color: showChart ? '#ffffff' : 'rgba(255, 255, 255, 0.5)', - border: '1px solid rgba(255, 255, 255, 0.1)', - borderRadius: 2, - padding: '6px', - '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.05)', - borderColor: 'rgba(255, 255, 255, 0.2)', - }, - }} - > - {showChart ? ( - - ) : ( - - )} - - + + { + setStatusFilter('all'); + setPage(0); + syncToUrl({ status: 'all', page: '0' }); + }} + /> + !r.inactiveAt).length} + color={STATUS_COLORS.success} + isActive={statusFilter === 'active'} + onClick={() => { + setStatusFilter('active'); + setPage(0); + syncToUrl({ status: 'active', page: '0' }); + }} + /> + !!r.inactiveAt).length} + color={STATUS_COLORS.closed} + isActive={statusFilter === 'inactive'} + onClick={() => { + setStatusFilter('inactive'); + setPage(0); + syncToUrl({ status: 'inactive', page: '0' }); + }} + /> + - {showChart && ( - setUseLogScale(e.target.checked)} - size="small" - sx={{ - '& .MuiSwitch-switchBase.Mui-checked': { - color: '#primary.main', - }, - '& .MuiSwitch-track': { - backgroundColor: 'rgba(255, 255, 255, 0.3)', - }, - }} - /> - } - label={ - - Log Scale - - } - /> - )} + + setShowChart(!showChart)} + size="small" + sx={{ + color: showChart ? 'text.primary' : 'text.tertiary', + border: '1px solid', + borderColor: 'border.light', + borderRadius: 2, + padding: '6px', + '&:hover': { + backgroundColor: 'surface.light', + borderColor: 'border.medium', + }, + }} + > + {showChart ? ( + + ) : ( + + )} + + - - + {showChart && ( + setUseLogScale(e.target.checked)} + size="small" + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { + color: 'primary.main', + }, + '& .MuiSwitch-track': { + backgroundColor: 'border.medium', + }, + }} + /> + } + label={ - Rows: + Log Scale - - - - - setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter' && isDirectRepoInput) { - onSelectRepository(trimmedSearch); - } - }} - InputProps={{ - startAdornment: ( - - - - ), - }} - sx={{ - width: '200px', - '& .MuiOutlinedInput-root': { - color: '#ffffff', - fontFamily: '"JetBrains Mono", monospace', - backgroundColor: 'rgba(0, 0, 0, 0.4)', + } + /> + )} + + + + + Rows: + + + + + + {isMobileSearchVisible ? ( + searchInput + ) : isMobile ? ( + setIsMobileSearchOpen(true)} + sx={{ + color: 'text.tertiary', + border: '1px solid', + borderColor: 'border.light', + borderRadius: 2, + width: 36, + height: 36, + '&:hover': { + backgroundColor: 'surface.light', + borderColor: 'border.medium', }, }} + > + + + ) : ( + searchInput + )} + + + + + {/* Row 2: Sort controls (card view only) */} + {viewMode === 'cards' && ( + + + Sort: + + + + handleSort(sortColumn)} + size="small" + aria-label={ + sortDirection === 'asc' ? 'Sort descending' : 'Sort ascending' + } + sx={{ + color: 'text.primary', + border: '1px solid', + borderColor: 'border.light', + borderRadius: 2, + padding: '6px', + '&:hover': { + backgroundColor: 'surface.light', + borderColor: 'border.medium', + }, + }} + > + {sortDirection === 'asc' ? ( + + ) : ( + + )} + + + + )} {showChart && filteredRepositories.length > 0 && ( @@ -649,253 +996,339 @@ const TopRepositoriesTable: React.FC = ({ - - - - - - Rank - - - Repository - - - Weight - - - Total Score - - - PRs - - - Contributors - - - - - {filteredRepositories - .slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage) - .map((repo) => { - return ( - onSelectRepository(repo.repository || '')} + {isLoading ? ( + + {Array.from({ length: rowsPerPage }).map((_, i) => ( + + alpha(t.palette.text.primary, 0.06), }} - > - - - - - + + ))} + + ) : pagedRepositories.length > 0 ? ( + + {pagedRepositories.map((repo) => ( + + + + ))} + + ) : trimmedSearch && isDirectRepoInput ? ( + + + Repository not in tracked list. Open details for{' '} + + {trimmedSearch} + + ? + + + + ) : ( + + No repositories match the current filters. + + )} + + )} + + {viewMode === 'list' && ( + +
+ + + + Rank + + + Repository + + + Weight + + + Total Score + + + PRs + + + Contributors + + + + + {isLoading ? ( + + ) : ( + <> + {pagedRepositories.map((repo) => { + return ( + - - + + + + + + + + + {truncateText(repo.repository || '', 40)} + + + + + - {truncateText(repo.repository || '', 40)} + {repo.weight.toFixed(2)} - - - - - - {repo.weight.toFixed(2)} - - - - 0 - ? '#fff' - : 'rgba(255,255,255,0.3)', - }} - > - {(repo.totalScore || 0) > 0 - ? Number(repo.totalScore || 0).toFixed(2) - : '-'} - - - - 0 - ? '#fff' - : 'rgba(255,255,255,0.3)', - }} - > - {(repo.totalPRs || 0) > 0 ? repo.totalPRs : '-'} - - - - 0 - ? '#fff' - : 'rgba(255,255,255,0.3)', - }} - > - {(repo.uniqueMiners?.size || 0) > 0 - ? repo.uniqueMiners?.size - : '-'} - - - - ); - })} - {!filteredRepositories.length && - trimmedSearch && - isDirectRepoInput && ( - - - - - Repository not in tracked list. Open details for{' '} - + - {trimmedSearch} - - ? - - - - - + 0 + ? 'text.primary' + : 'text.secondary', + }} + > + {(repo.totalScore || 0) > 0 + ? Number(repo.totalScore || 0).toFixed(2) + : '-'} + + + + 0 + ? 'text.primary' + : 'text.secondary', + }} + > + {(repo.totalPRs || 0) > 0 ? repo.totalPRs : '-'} + + + + 0 + ? 'text.primary' + : 'text.secondary', + }} + > + {(repo.uniqueMiners?.size || 0) > 0 + ? repo.uniqueMiners?.size + : '-'} + + + + ); + })} + {!filteredRepositories.length && + trimmedSearch && + isDirectRepoInput && ( + + + + + Repository not in tracked list. Open details for{' '} + + {trimmedSearch} + + ? + + + + + + )} + )} - -
-
+ + + + )} = ({ showFirstButton showLastButton sx={{ - borderTop: '1px solid rgba(255, 255, 255, 0.1)', - color: 'rgba(255, 255, 255, 0.7)', - '.MuiTablePagination-displayedRows': { - fontFamily: '"JetBrains Mono", monospace', - }, + borderTop: '1px solid', + borderColor: 'border.light', + color: 'text.secondary', + '.MuiTablePagination-displayedRows': {}, }} />
); }; -const headerCellStyle = { - backgroundColor: 'rgba(18, 18, 20, 0.95)', - backdropFilter: 'blur(8px)', - color: '#ffffff', - fontFamily: '"JetBrains Mono", monospace', - fontWeight: 500, - fontSize: '0.75rem', - borderBottom: '1px solid rgba(255, 255, 255, 0.1)', - height: '48px', - py: 1, - boxSizing: 'border-box' as const, -}; +interface ViewModeToggleProps { + viewMode: ViewMode; + onChange: (mode: ViewMode) => void; +} + +const ViewModeToggle: React.FC = ({ + viewMode, + onChange, +}) => { + const options: { + value: ViewMode; + label: string; + Icon: typeof ViewListIcon; + }[] = [ + { value: 'list', label: 'List view', Icon: ViewListIcon }, + { value: 'cards', label: 'Card view', Icon: ViewModuleIcon }, + ]; -const bodyCellStyle = { - color: '#ffffff', - fontFamily: '"JetBrains Mono", monospace', - borderBottom: '1px solid rgba(255, 255, 255, 0.1)', - fontSize: '0.75rem', - py: 0.75, - height: '52px', - boxSizing: 'border-box' as const, + return ( + ({ + display: 'inline-flex', + alignItems: 'center', + borderRadius: 2, + border: '1px solid', + borderColor: theme.palette.border.light, + overflow: 'hidden', + })} + role="group" + aria-label="Toggle view mode" + > + {options.map(({ value, label, Icon }) => { + const isActive = viewMode === value; + return ( + + onChange(value)} + size="small" + aria-label={label} + aria-pressed={isActive} + sx={(theme) => ({ + borderRadius: 0, + padding: '6px 10px', + color: isActive + ? theme.palette.text.primary + : theme.palette.text.tertiary, + backgroundColor: isActive + ? theme.palette.surface.light + : 'transparent', + '&:hover': { + backgroundColor: theme.palette.surface.light, + color: theme.palette.text.primary, + }, + })} + > + + + + ); + })} + + ); }; export default TopRepositoriesTable; diff --git a/src/components/leaderboard/index.ts b/src/components/leaderboard/index.ts index d5d46316..a372513a 100644 --- a/src/components/leaderboard/index.ts +++ b/src/components/leaderboard/index.ts @@ -1,12 +1,13 @@ // Components export { default as TopMinersTable } from './TopMinersTable'; -export { default as TopPRsTable } from './TopPRsTable'; export { default as TopRepositoriesTable } from './TopRepositoriesTable'; export { LeaderboardSidebar } from './LeaderboardSidebar'; +export { ActivitySidebarCards, StatRow } from './ActivitySidebarCards'; export { MinerCard } from './MinerCard'; +export { MinersList } from './MinersList'; export { RankIcon } from './RankIcon'; export { SectionCard } from './SectionCard'; // Types and utilities export type { MinerStats, SortOption } from './types'; -export { FONTS, getRankColors } from './types'; +export { FONTS, getRankColors, headerCellStyle, bodyCellStyle } from './types'; diff --git a/src/components/leaderboard/repositoriesViewMode.ts b/src/components/leaderboard/repositoriesViewMode.ts new file mode 100644 index 00000000..25758900 --- /dev/null +++ b/src/components/leaderboard/repositoriesViewMode.ts @@ -0,0 +1,57 @@ +export type RepositoriesViewMode = 'cards' | 'list'; + +export const REPOSITORIES_VIEW_QUERY_PARAM = 'view'; +export const REPOSITORIES_VIEW_STORAGE_KEY = 'repositories:viewMode'; + +export const readStoredRepositoriesViewMode = (): RepositoriesViewMode => { + try { + return window.localStorage.getItem(REPOSITORIES_VIEW_STORAGE_KEY) === + 'cards' + ? 'cards' + : 'list'; + } catch { + return 'list'; + } +}; + +export const writeStoredRepositoriesViewMode = ( + mode: RepositoriesViewMode, +): void => { + try { + window.localStorage.setItem(REPOSITORIES_VIEW_STORAGE_KEY, mode); + } catch { + // localStorage unavailable (private mode, quota) — preference won't persist + } +}; + +export const getRepositoriesViewModeFromQuery = ( + value: string | null, + fallback: RepositoriesViewMode, +): RepositoriesViewMode => { + if (value === 'cards') return 'cards'; + if (value === 'list') return 'list'; + return fallback; +}; + +// Card grid is up to 3 cols (lg/md), 2 (sm), 1 (xs). These page sizes +// divide evenly by 1, 2 and 3, so the last row of cards is never partial. +export const REPOSITORIES_LIST_ROWS = [10, 25, 50] as const; +export const REPOSITORIES_CARD_ROWS = [12, 24, 48] as const; +export const REPOSITORIES_VALID_ROWS: readonly number[] = [ + ...REPOSITORIES_LIST_ROWS, + ...REPOSITORIES_CARD_ROWS, +]; +export const REPOSITORIES_DEFAULT_LIST_ROWS = 10; +export const REPOSITORIES_DEFAULT_CARD_ROWS = 12; + +export const clampRowsForRepositoriesView = ( + rows: number, + mode: RepositoriesViewMode, +): number => { + const options = + mode === 'cards' ? REPOSITORIES_CARD_ROWS : REPOSITORIES_LIST_ROWS; + if ((options as readonly number[]).includes(rows)) return rows; + return mode === 'cards' + ? REPOSITORIES_DEFAULT_CARD_ROWS + : REPOSITORIES_DEFAULT_LIST_ROWS; +}; diff --git a/src/components/leaderboard/types.ts b/src/components/leaderboard/types.ts index 4c825279..5e6f2416 100644 --- a/src/components/leaderboard/types.ts +++ b/src/components/leaderboard/types.ts @@ -1,4 +1,8 @@ -import { RANK_COLORS } from '../../theme'; +import { + RANK_COLORS, + REPO_OWNER_AVATAR_BACKGROUNDS, + STATUS_COLORS, +} from '../../theme'; export interface MinerStats { id: string; @@ -7,6 +11,7 @@ export interface MinerStats { totalScore: number; baseTotalScore: number; totalPRs: number; + totalIssues?: number; linesChanged: number; linesAdded: number; linesDeleted: number; @@ -15,16 +20,44 @@ export interface MinerStats { uniqueReposCount?: number; credibility?: number; isEligible?: boolean; + /** + * Program-specific eligibility flags. + * + * These allow UI surfaces like Watchlist to disambiguate eligibility between + * OSS contributions and Issue Discoveries without changing the default + * `isEligible` semantics used across leaderboards. + */ + ossIsEligible?: boolean; + discoveriesIsEligible?: boolean; usdPerDay?: number; totalMergedPrs?: number; totalOpenPrs?: number; totalClosedPrs?: number; + totalSolvedIssues?: number; + totalOpenIssues?: number; + totalClosedIssues?: number; + issueDiscoveryScore?: number; + issueCredibility?: number; + isIssueEligible?: boolean; } +export interface RepoStats { + repository: string; + totalScore: number; + totalPRs: number; + uniqueMiners: Set; + weight: number; + rank?: number; + inactiveAt?: string | null; +} + +export type LeaderboardVariant = 'oss' | 'discoveries' | 'watchlist'; + export type SortOption = | 'totalScore' | 'usdPerDay' | 'totalPRs' + | 'totalIssues' | 'credibility'; export const FONTS = { @@ -35,5 +68,36 @@ export const getRankColors = (rank: number) => { if (rank === 1) return { color: RANK_COLORS.first, icon: '🥇' }; if (rank === 2) return { color: RANK_COLORS.second, icon: '🥈' }; if (rank === 3) return { color: RANK_COLORS.third, icon: '🥉' }; - return { color: 'rgba(255, 255, 255, 0.6)', icon: null }; + return { color: STATUS_COLORS.open, icon: null }; +}; + +export const getRepositoryOwnerAvatarBackground = (owner: string) => { + if (owner === 'opentensor') return REPO_OWNER_AVATAR_BACKGROUNDS.opentensor; + if (owner === 'bitcoin') return REPO_OWNER_AVATAR_BACKGROUNDS.bitcoin; + return 'transparent'; +}; + +export const headerCellStyle = { + backgroundColor: 'surface.tooltip', + backdropFilter: 'blur(8px)', + color: 'text.primary', + fontFamily: FONTS.mono, + fontWeight: 500, + fontSize: '0.75rem', + borderBottom: '1px solid', + borderColor: 'border.light', + height: '48px', + py: 1, + boxSizing: 'border-box' as const, +}; + +export const bodyCellStyle = { + color: 'text.primary', + fontFamily: FONTS.mono, + borderBottom: '1px solid', + borderColor: 'border.light', + fontSize: '0.75rem', + py: 0.75, + height: '52px', + boxSizing: 'border-box' as const, }; diff --git a/src/components/miners/CredibilityChart.tsx b/src/components/miners/CredibilityChart.tsx index 958c0792..76de02c8 100644 --- a/src/components/miners/CredibilityChart.tsx +++ b/src/components/miners/CredibilityChart.tsx @@ -1,7 +1,7 @@ import React, { useMemo } from 'react'; -import { Box, Typography } from '@mui/material'; +import { Box, Typography, alpha, useTheme } from '@mui/material'; import ReactECharts from 'echarts-for-react'; -import { CHART_COLORS } from '../../theme'; +import { CHART_COLORS, TEXT_OPACITY } from '../../theme'; interface CredibilityChartProps { merged: number; @@ -16,6 +16,8 @@ const CredibilityChart: React.FC = ({ closed, credibility, }) => { + const theme = useTheme(); + const chartOption = useMemo( () => ({ backgroundColor: 'transparent', @@ -25,27 +27,24 @@ const CredibilityChart: React.FC = ({ left: 'center', top: '38%', textStyle: { - color: '#fff', + color: theme.palette.text.primary, fontSize: 28, fontWeight: 'bold', - fontFamily: '"JetBrains Mono", monospace', }, subtextStyle: { - color: 'rgba(255, 255, 255, 0.4)', + color: alpha(theme.palette.common.white, TEXT_OPACITY.muted), fontSize: 11, - fontFamily: '"JetBrains Mono", monospace', fontWeight: 500, }, }, tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)', - backgroundColor: 'rgba(0, 0, 0, 0.9)', - borderColor: 'rgba(255, 255, 255, 0.15)', + backgroundColor: alpha(theme.palette.common.black, 0.9), + borderColor: alpha(theme.palette.common.white, 0.15), borderWidth: 1, textStyle: { - color: '#fff', - fontFamily: '"JetBrains Mono", monospace', + color: theme.palette.text.primary, }, }, series: [ @@ -56,7 +55,7 @@ const CredibilityChart: React.FC = ({ avoidLabelOverlap: false, itemStyle: { borderRadius: 6, - borderColor: '#0d1117', + borderColor: theme.palette.background.paper, borderWidth: 3, }, label: { show: false, position: 'center' }, @@ -82,7 +81,7 @@ const CredibilityChart: React.FC = ({ }, ], }), - [merged, open, closed, credibility], + [merged, open, closed, credibility, theme], ); return ( @@ -96,7 +95,7 @@ const CredibilityChart: React.FC = ({ = ({ label, value, color, -}) => ( - - - - {label} - - - {value} - - -); +}) => { + const theme = useTheme(); + + return ( + + + + {label} + + + {value} + + + ); +}; export default CredibilityChart; diff --git a/src/components/miners/EmptyStateMessage.tsx b/src/components/miners/EmptyStateMessage.tsx index 0c376ac0..56556929 100644 --- a/src/components/miners/EmptyStateMessage.tsx +++ b/src/components/miners/EmptyStateMessage.tsx @@ -11,7 +11,6 @@ const EmptyStateMessage: React.FC = ({ message }) => { alpha(t.palette.text.primary, 0.5), - fontFamily: '"JetBrains Mono", monospace', fontSize: '0.9rem', }} > diff --git a/src/components/miners/ExplorerFilterButton.tsx b/src/components/miners/ExplorerFilterButton.tsx index 6e2fd683..bf899a2d 100644 --- a/src/components/miners/ExplorerFilterButton.tsx +++ b/src/components/miners/ExplorerFilterButton.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Button, useTheme } from '@mui/material'; +import { Button } from '@mui/material'; interface ExplorerFilterButtonProps { label: string; @@ -16,7 +16,6 @@ const ExplorerFilterButton: React.FC = ({ selected, onClick, }) => { - const theme = useTheme(); return (
+ + Dive Deeper + + + Learn about the exact formulas, multipliers, and weight calculations + in our detailed documentation. + + + - -); + ); +}; diff --git a/src/components/prs/PRComments.tsx b/src/components/prs/PRComments.tsx index 67d9ae24..3680892f 100644 --- a/src/components/prs/PRComments.tsx +++ b/src/components/prs/PRComments.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { formatDate } from '../../utils/format'; import { Box, Typography, @@ -8,6 +9,7 @@ import { CircularProgress, Chip, } from '@mui/material'; +import { alpha } from '@mui/material/styles'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; @@ -16,7 +18,7 @@ import { type PullRequestComment, type PullRequestDetails, } from '../../api/models/Dashboard'; -import { STATUS_COLORS } from '../../theme'; +import { STATUS_COLORS, UI_COLORS, scrollbarSx } from '../../theme'; import 'github-markdown-css/github-markdown-dark.css'; // Import standard GitHub Dark styles /** A comment or the PR description rendered in the conversation timeline. */ @@ -79,26 +81,25 @@ const PRComments: React.FC = ({ ...comments, ]; - // Premium Dark Theme Colors const colors = { canvas: { - default: '#0d1117', - subtle: '#161b22', - box: '#0d1117', + default: UI_COLORS.black, + subtle: UI_COLORS.surfaceElevated, + box: UI_COLORS.black, }, border: { - default: '#30363d', - muted: '#21262d', + default: alpha(UI_COLORS.white, 0.1), + muted: alpha(UI_COLORS.white, 0.08), }, fg: { - default: '#c9d1d9', + default: alpha(UI_COLORS.white, 0.85), muted: STATUS_COLORS.open, }, accent: { fg: STATUS_COLORS.info, }, timeline: { - line: '#30363d', + line: alpha(UI_COLORS.white, 0.1), }, }; @@ -147,8 +148,8 @@ const PRComments: React.FC = ({ sx={{ width: 40, height: 40, - border: '1px solid rgba(255,255,255,0.1)', - backgroundColor: '#0d1117', // Avoid transparency issues over the line + border: `1px solid ${alpha(UI_COLORS.white, 0.1)}`, + backgroundColor: UI_COLORS.black, }} /> @@ -231,11 +232,7 @@ const PRComments: React.FC = ({ component="span" sx={{ fontSize: 'inherit', color: 'inherit' }} > - {new Date(item.createdAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - })} + {formatDate(item.createdAt)} @@ -262,7 +259,7 @@ const PRComments: React.FC = ({ label="Description" sx={{ color: STATUS_COLORS.info, - borderColor: 'rgba(56, 139, 253, 0.4)', + borderColor: alpha(STATUS_COLORS.info, 0.4), }} /> )} @@ -279,6 +276,7 @@ const PRComments: React.FC = ({ fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"', // GitHub's exact font stack overflowX: 'auto', + ...scrollbarSx, // Typography refinements '& > *:first-of-type': { mt: 0 }, '& > *:last-child': { mb: 0 }, @@ -320,9 +318,8 @@ const PRComments: React.FC = ({ padding: '0.2em 0.4em', margin: 0, fontSize: '85%', - backgroundColor: 'rgba(110, 118, 129, 0.4)', + backgroundColor: alpha(STATUS_COLORS.neutral, 0.4), borderRadius: '6px', - fontFamily: '"JetBrains Mono", monospace', }, '& pre': { mt: 2, @@ -330,7 +327,7 @@ const PRComments: React.FC = ({ p: 2, borderRadius: '6px', overflow: 'auto', - backgroundColor: '#161b22', + backgroundColor: UI_COLORS.surfaceElevated, border: `1px solid ${colors.border.default}`, '& code': { backgroundColor: 'transparent', @@ -350,7 +347,7 @@ const PRComments: React.FC = ({ }, '& th': { fontWeight: 600 }, '& tr:nth-of-type(2n)': { - backgroundColor: '#161b22', + backgroundColor: UI_COLORS.surfaceElevated, }, '& hr': { height: '0.25em', diff --git a/src/components/prs/PRDetailsCard.tsx b/src/components/prs/PRDetailsCard.tsx index 176ffb09..5930eabb 100644 --- a/src/components/prs/PRDetailsCard.tsx +++ b/src/components/prs/PRDetailsCard.tsx @@ -11,8 +11,9 @@ import { } from '@mui/material'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { usePullRequestDetails } from '../../api'; -import { useNavigate } from 'react-router-dom'; -import theme, { RANK_COLORS, STATUS_COLORS } from '../../theme'; +import { linkResetSx, useLinkBehavior } from '../common/linkBehavior'; +import theme, { RANK_COLORS, STATUS_COLORS, TEXT_OPACITY } from '../../theme'; +import { buildMultiplierGrid } from '../../utils/multiplierDefs'; interface PRDetailsCardProps { repository: string; @@ -25,18 +26,27 @@ const PRDetailsCard: React.FC = ({ pullRequestNumber, hideHeader = false, }) => { - const navigate = useNavigate(); // Fetch detailed PR data directly const { data: prDetails, isLoading: isDetailsLoading } = usePullRequestDetails(repository, pullRequestNumber); + const repoLinkProps = useLinkBehavior( + `/miners/repository?name=${encodeURIComponent(repository)}`, + { state: { backLabel: `Back to PR #${pullRequestNumber}` } }, + ); + const authorLinkProps = useLinkBehavior( + `/miners/details?githubId=${prDetails?.githubId ?? ''}`, + { state: { backLabel: `Back to PR #${pullRequestNumber}` } }, + ); + if (isDetailsLoading) { return ( = ({ return ( @@ -80,7 +90,7 @@ const PRDetailsCard: React.FC = ({ label: 'Base Score', value: parseFloat(prDetails.baseScore ?? '0').toFixed(2), rank: null, - color: 'rgba(255, 255, 255, 0.7)', + color: alpha(theme.palette.common.white, TEXT_OPACITY.secondary), }, { label: 'Tokens Scored', @@ -92,6 +102,26 @@ const PRDetailsCard: React.FC = ({ value: parseFloat(prDetails.tokenScore ?? '0').toFixed(2), rank: null, }, + { + label: 'Structural', + value: + prDetails.structuralCount != null + ? `${prDetails.structuralCount} (${parseFloat(String(prDetails.structuralScore ?? 0)).toFixed(2)})` + : '-', + rank: null, + tooltip: + 'Functions, classes, and modules scored via AST analysis. Structural nodes carry more weight per node because they represent high-value code organization.', + }, + { + label: 'Leaf', + value: + prDetails.leafCount != null + ? `${prDetails.leafCount} (${parseFloat(String(prDetails.leafScore ?? 0)).toFixed(2)})` + : '-', + rank: null, + tooltip: + 'Individual statements and expressions scored via AST analysis. More leaf nodes means a larger diff, but structural nodes contribute more score per node.', + }, { label: 'Changes', value: '', @@ -106,56 +136,13 @@ const PRDetailsCard: React.FC = ({ }, ]; - // For OPEN PRs: collateral = base_score × repo_weight × issue_multiplier × 20% - // Only show applicable multipliers - const multipliers: Array<{ - label: string; - value: string; - isCredibility?: boolean; - }> = isOpenPR - ? [ - { - label: 'Repo Weight', - value: `${parseFloat(prDetails.repoWeightMultiplier ?? '0').toFixed(2)}x`, - }, - { - label: 'Issue Bonus', - value: `${parseFloat(prDetails.issueMultiplier ?? '0').toFixed(2)}x`, - }, - { - label: 'Collateral %', - value: '20%', - }, - ] - : [ - { - label: 'Repo Weight', - value: `${parseFloat(prDetails.repoWeightMultiplier ?? '0').toFixed(2)}x`, - }, - { - label: 'Issue Bonus', - value: `${parseFloat(prDetails.issueMultiplier ?? '0').toFixed(2)}x`, - }, - { - label: 'Credibility', - value: `${parseFloat(prDetails.credibilityMultiplier ?? '0').toFixed(2)}x`, - isCredibility: true, - }, - { - label: 'Review Quality', - value: `${parseFloat(prDetails.reviewQualityMultiplier ?? '0').toFixed(2)}x`, - }, - { - label: 'Time Decay', - value: `${parseFloat(prDetails.timeDecayMultiplier ?? '0').toFixed(2)}x`, - }, - ]; + const multipliers = buildMultiplierGrid(prDetails, isOpenPR); return ( = ({ {!hideHeader && ( - navigate( - `/miners/repository?name=${encodeURIComponent(repository)}`, - { state: { backLabel: `Back to PR #${pullRequestNumber}` } }, - ) - } + component="a" + {...repoLinkProps} sx={{ + ...linkResetSx, cursor: 'pointer', transition: 'transform 0.2s', '&:hover': { @@ -185,10 +169,10 @@ const PRDetailsCard: React.FC = ({ sx={{ width: 64, height: 64, - border: '2px solid rgba(255, 255, 255, 0.2)', + border: `2px solid ${alpha(theme.palette.common.white, 0.2)}`, backgroundColor: owner === 'opentensor' - ? '#ffffff' + ? theme.palette.common.white : owner === 'bitcoin' ? '#F7931A' : 'transparent', @@ -202,8 +186,7 @@ const PRDetailsCard: React.FC = ({ = ({ = ({ - navigate( - `/miners/repository?name=${encodeURIComponent(repository)}`, - { - state: { backLabel: `Back to PR #${pullRequestNumber}` }, - }, - ) - } + component="a" + {...repoLinkProps} sx={{ - color: 'rgba(255, 255, 255, 0.5)', - fontFamily: '"JetBrains Mono", monospace', + ...linkResetSx, + color: alpha( + theme.palette.common.white, + TEXT_OPACITY.tertiary, + ), fontSize: '0.85rem', cursor: 'pointer', transition: 'color 0.2s', @@ -290,7 +270,7 @@ const PRDetailsCard: React.FC = ({ sx={{ backgroundColor: 'transparent', borderRadius: 3, - border: '1px solid rgba(255, 255, 255, 0.1)', + border: `1px solid ${theme.palette.border.light}`, p: 2.5, height: '100%', display: 'flex', @@ -308,20 +288,53 @@ const PRDetailsCard: React.FC = ({ > {item.label} + {item.tooltip && ( + + + + )} {item.rank && ( = ({ ? alpha(RANK_COLORS.second, 0.4) : item.rank === 3 ? alpha(RANK_COLORS.third, 0.4) - : 'rgba(255, 255, 255, 0.15)', + : alpha(theme.palette.common.white, 0.15), boxShadow: item.rank === 1 ? `0 0 12px ${alpha(RANK_COLORS.first, 0.4)}, 0 0 4px ${alpha(RANK_COLORS.first, 0.2)}` @@ -358,8 +371,7 @@ const PRDetailsCard: React.FC = ({ ? RANK_COLORS.second : item.rank === 3 ? RANK_COLORS.third - : 'rgba(255, 255, 255, 0.6)', - fontFamily: '"JetBrains Mono", monospace', + : alpha(theme.palette.common.white, 0.6), fontSize: '0.6rem', fontWeight: 600, lineHeight: 1, @@ -379,7 +391,6 @@ const PRDetailsCard: React.FC = ({ component="span" sx={{ color: alpha(theme.palette.diff.additions, 0.8), - fontFamily: '"JetBrains Mono", monospace', fontSize: '1.5rem', fontWeight: 600, }} @@ -389,8 +400,10 @@ const PRDetailsCard: React.FC = ({ = ({ component="span" sx={{ color: alpha(theme.palette.diff.deletions, 0.8), - fontFamily: '"JetBrains Mono", monospace', fontSize: '1.5rem', fontWeight: 600, }} @@ -412,8 +424,7 @@ const PRDetailsCard: React.FC = ({ ) : ( = ({ = ({ const content = ( = ({ > = ({ = ({ This multiplier is based on your PR success rate, @@ -516,15 +528,15 @@ const PRDetailsCard: React.FC = ({ slotProps={{ tooltip: { sx: { - backgroundColor: 'rgba(20, 20, 20, 0.98)', - border: '1px solid rgba(255, 255, 255, 0.15)', + backgroundColor: 'surface.tooltip', + border: `1px solid ${alpha(theme.palette.common.white, 0.15)}`, borderRadius: '8px', maxWidth: 280, }, }, arrow: { sx: { - color: 'rgba(20, 20, 20, 0.98)', + color: 'surface.tooltip', }, }, }} @@ -548,15 +560,14 @@ const PRDetailsCard: React.FC = ({ sx={{ backgroundColor: 'transparent', borderRadius: 3, - border: '1px solid rgba(255, 255, 255, 0.1)', + border: `1px solid ${theme.palette.border.light}`, p: 2.5, height: '100%', }} > = ({ Author - navigate(`/miners/details?githubId=${prDetails.githubId}`, { - state: { backLabel: `Back to PR #${pullRequestNumber}` }, - }) - } + component="a" + {...authorLinkProps} sx={{ + ...linkResetSx, display: 'flex', alignItems: 'center', gap: 1.5, @@ -593,8 +602,7 @@ const PRDetailsCard: React.FC = ({ /> = ({ sx={{ backgroundColor: 'transparent', borderRadius: 3, - border: '1px solid rgba(255, 255, 255, 0.1)', + border: `1px solid ${theme.palette.border.light}`, p: 2.5, height: '100%', }} > = ({ = ({ sx={{ backgroundColor: 'transparent', borderRadius: 3, - border: '1px solid rgba(255, 255, 255, 0.1)', + border: `1px solid ${theme.palette.border.light}`, p: 2.5, }} > = ({ = ({ sx={{ backgroundColor: 'transparent', borderRadius: 3, - border: '1px solid rgba(255, 255, 255, 0.1)', + border: `1px solid ${theme.palette.border.light}`, p: 2.5, display: 'flex', alignItems: 'center', @@ -701,8 +708,10 @@ const PRDetailsCard: React.FC = ({ > @@ -715,7 +724,6 @@ const PRDetailsCard: React.FC = ({ style={{ color: STATUS_COLORS.info, textDecoration: 'none', - fontFamily: '"JetBrains Mono", monospace', fontSize: '0.85rem', fontWeight: 500, }} diff --git a/src/components/prs/PRFilesChanged.tsx b/src/components/prs/PRFilesChanged.tsx index 44eecf13..7c7b5f30 100644 --- a/src/components/prs/PRFilesChanged.tsx +++ b/src/components/prs/PRFilesChanged.tsx @@ -44,7 +44,7 @@ import Tooltip from '@mui/material/Tooltip'; import IconButton from '@mui/material/IconButton'; import FormControlLabel from '@mui/material/FormControlLabel'; import Switch from '@mui/material/Switch'; -import { STATUS_COLORS } from '../../theme'; +import { STATUS_COLORS, DIFF_COLORS, scrollbarSx } from '../../theme'; interface PRFile { sha: string; @@ -88,6 +88,11 @@ type SplitDiffRow = | { type: 'normal'; left: Change; right: Change } | { type: 'modify'; left: Change | null; right: Change | null }; +const selectedFileBackground = alpha(STATUS_COLORS.info, 0.15); +const addedLineBackground = alpha(DIFF_COLORS.additions, 0.15); +const deletedLineBackground = alpha(DIFF_COLORS.deletions, 0.15); +const unchangedFileColor = alpha(STATUS_COLORS.open, 0.5); + const buildFullTree = ( allFilesParams: { path: string; type: 'blob' | 'tree' }[], changedFiles: PRFile[], @@ -177,7 +182,7 @@ const FileTreeItem: React.FC<{ const getIcon = () => { if (hasChildren) { - const color = node.hasChanges ? '#d29922' : STATUS_COLORS.open; // Orange folder if changes inside + const color = node.hasChanges ? 'status.warning' : 'status.open'; // Orange folder if changes inside return open ? ( ) : ( @@ -186,14 +191,14 @@ const FileTreeItem: React.FC<{ } // File icons - let color: string = STATUS_COLORS.open; + let color: string = 'status.open'; if (node.file) { - if (node.file.status === 'added') color = '#2da44e'; - if (node.file.status === 'removed') color = '#cf222e'; - if (node.file.status === 'modified') color = '#d29922'; + if (node.file.status === 'added') color = 'status.success'; + if (node.file.status === 'removed') color = 'status.error'; + if (node.file.status === 'modified') color = 'status.warning'; } else { // Unchanged file - color = 'rgba(139, 148, 158, 0.5)'; + color = unchangedFileColor; } return ; }; @@ -208,16 +213,13 @@ const FileTreeItem: React.FC<{ py: 0.25, minHeight: 28, height: 'auto', - backgroundColor: isSelected - ? 'rgba(56, 139, 253, 0.15)' - : 'transparent', - borderLeft: isSelected - ? '2px solid #388bfd' - : '2px solid transparent', + backgroundColor: isSelected ? selectedFileBackground : 'transparent', + borderLeft: '2px solid', + borderLeftColor: isSelected ? 'status.info' : 'transparent', '&:hover': { backgroundColor: isSelected - ? 'rgba(56, 139, 253, 0.15)' - : 'rgba(255, 255, 255, 0.04)', + ? selectedFileBackground + : 'surface.light', }, opacity: node.file || node.hasChanges ? 1 : 0.6, // Dim unchanged items }} @@ -272,7 +274,7 @@ const FileTreeItem: React.FC<{ width: 6, height: 6, borderRadius: '50%', - bgcolor: '#d29922', + bgcolor: 'status.warning', }} /> )} @@ -280,13 +282,12 @@ const FileTreeItem: React.FC<{ } primaryTypographyProps={{ sx: { - fontFamily: '"JetBrains Mono", monospace', fontSize: '12px', color: isSelected - ? '#fff' + ? 'text.primary' : node.file || node.hasChanges - ? '#c9d1d9' - : STATUS_COLORS.open, + ? 'text.tertiary' + : 'status.open', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', @@ -391,8 +392,7 @@ const SplitDiffView: React.FC<{ patch: string; lineWrap: boolean }> = ({ className="split-diff-table" sx={{ overflowX: 'auto', - backgroundColor: '#0d1117', - fontFamily: '"JetBrains Mono", monospace', + backgroundColor: 'background.paper', fontSize: '12px', }} > @@ -407,12 +407,16 @@ const SplitDiffView: React.FC<{ patch: string; lineWrap: boolean }> = ({ {rows.map((row, idx) => { if (row.type === 'chunk-header') { return ( - + = ({ = ({ = ({ = ({ verticalAlign: 'top', backgroundColor: row.right?.type === 'add' - ? 'rgba(46,160,67,0.15)' + ? addedLineBackground : 'transparent', - color: '#e6edf3', + color: 'text.primary', whiteSpace: 'pre-wrap', wordBreak: 'break-all', p: '4px 8px', @@ -555,12 +562,9 @@ const SplitDiffView: React.FC<{ patch: string; lineWrap: boolean }> = ({ sx={{ width: '50%', overflowX: 'auto', - borderRight: side === 'left' ? '1px solid #30363d' : 'none', - '&::-webkit-scrollbar': { height: '8px' }, - '&::-webkit-scrollbar-thumb': { - backgroundColor: '#30363d', - borderRadius: '4px', - }, + borderRight: side === 'left' ? '1px solid' : 'none', + borderColor: 'border.light', + ...scrollbarSx, }} > = ({ width: '100%', minWidth: 'max-content', tableLayout: 'auto', + borderCollapse: 'separate', + borderSpacing: 0, }} > @@ -577,7 +583,10 @@ const SplitDiffView: React.FC<{ patch: string; lineWrap: boolean }> = ({ return ( = ({ left: 0, width: '50px', minWidth: '50px', - backgroundColor: '#1c2128', - borderBottom: '1px solid #30363d', - borderRight: '1px solid #30363d', + backgroundColor: 'surface.elevated', + borderBottom: '1px solid', + borderRight: '1px solid', + borderColor: 'border.light', p: '4px 8px', - color: STATUS_COLORS.open, + color: 'status.open', fontFamily: 'inherit', fontSize: '12px', - zIndex: 2, + zIndex: 5, + isolation: 'isolate', }} > ... = ({ : ''; let bg = 'transparent'; - if (item && item.type === 'add') bg = 'rgba(46, 160, 67, 0.15)'; - if (item && item.type === 'del') bg = 'rgba(248, 81, 73, 0.15)'; + if (item && item.type === 'add') bg = addedLineBackground; + if (item && item.type === 'del') bg = deletedLineBackground; return ( @@ -634,9 +646,14 @@ const SplitDiffView: React.FC<{ patch: string; lineWrap: boolean }> = ({ left: 0, width: '50px', minWidth: '50px', - backgroundColor: bg === 'transparent' ? '#0d1117' : bg, - color: '#6e7681', - borderRight: '1px solid #30363d', + backgroundColor: 'background.paper', + // Isolated sticky cells paint their own layer, so preserve diff tint via a same-color gradient. + ...(bg !== 'transparent' && { + backgroundImage: `linear-gradient(${bg}, ${bg})`, + }), + color: 'status.open', + borderRight: '1px solid', + borderColor: 'border.light', borderBottom: 'none', textAlign: 'right', verticalAlign: 'top', @@ -645,7 +662,8 @@ const SplitDiffView: React.FC<{ patch: string; lineWrap: boolean }> = ({ fontFamily: 'inherit', fontSize: '12px', lineHeight: '24px', - zIndex: 2, + zIndex: 5, + isolation: 'isolate', }} > {ln} @@ -653,7 +671,7 @@ const SplitDiffView: React.FC<{ patch: string; lineWrap: boolean }> = ({ = ({ sx={{ display: 'flex', width: '100%', - backgroundColor: '#0d1117', - fontFamily: '"JetBrains Mono", monospace', + backgroundColor: 'background.paper', fontSize: '12px', }} > @@ -718,8 +735,7 @@ const UnifiedDiffView: React.FC<{ patch: string; lineWrap: boolean }> = ({ @@ -736,12 +752,16 @@ const UnifiedDiffView: React.FC<{ patch: string; lineWrap: boolean }> = ({ {rows.map((row, idx) => { if (row.type === 'chunk-header') { return ( - + = ({ whiteSpace: 'pre', }} > - {(row as any).content} + {row.content} ); @@ -757,8 +777,8 @@ const UnifiedDiffView: React.FC<{ patch: string; lineWrap: boolean }> = ({ const change = row as Change; let bg = 'transparent'; - if (change.type === 'add') bg = 'rgba(46, 160, 67, 0.15)'; - if (change.type === 'del') bg = 'rgba(248, 81, 73, 0.15)'; + if (change.type === 'add') bg = addedLineBackground; + if (change.type === 'del') bg = deletedLineBackground; return ( @@ -767,9 +787,11 @@ const UnifiedDiffView: React.FC<{ patch: string; lineWrap: boolean }> = ({ sx={{ width: '50px', minWidth: '50px', - backgroundColor: bg === 'transparent' ? '#0d1117' : bg, - color: '#6e7681', - borderRight: '1px solid #30363d', + backgroundColor: + bg === 'transparent' ? 'background.paper' : bg, + color: 'status.open', + borderRight: '1px solid', + borderColor: 'border.light', borderBottom: 'none', textAlign: 'right', verticalAlign: 'top', @@ -792,9 +814,11 @@ const UnifiedDiffView: React.FC<{ patch: string; lineWrap: boolean }> = ({ sx={{ width: '50px', minWidth: '50px', - backgroundColor: bg === 'transparent' ? '#0d1117' : bg, - color: '#6e7681', - borderRight: '1px solid #30363d', + backgroundColor: + bg === 'transparent' ? 'background.paper' : bg, + color: 'status.open', + borderRight: '1px solid', + borderColor: 'border.light', borderBottom: 'none', textAlign: 'right', verticalAlign: 'top', @@ -816,7 +840,7 @@ const UnifiedDiffView: React.FC<{ patch: string; lineWrap: boolean }> = ({ @@ -999,8 +1023,8 @@ const DiffMinimap: React.FC<{ const top = (i / totalLines) * 100; const height = (1 / totalLines) * 100; let color = 'transparent'; - if (line.type === 'add') color = '#2da44e'; - if (line.type === 'del') color = '#cf222e'; + if (line.type === 'add') color = DIFF_COLORS.additions; + if (line.type === 'del') color = DIFF_COLORS.deletions; if (color === 'transparent') return null; return ( @@ -1028,15 +1052,16 @@ const DiffMinimap: React.FC<{ left: 0, right: 0, height: `${overlayHeightPct}%`, - backgroundColor: 'rgba(255, 255, 255, 0.1)', - borderTop: '1px solid rgba(255, 255, 255, 0.2)', - borderBottom: '1px solid rgba(255, 255, 255, 0.2)', + backgroundColor: 'border.light', + borderTop: '1px solid', + borderBottom: '1px solid', + borderColor: 'border.medium', transition: isDragging ? 'none' : 'top 0.1s', zIndex: 2, cursor: 'grab', '&:active': { cursor: 'grabbing', - backgroundColor: 'rgba(255, 255, 255, 0.2)', + backgroundColor: 'border.medium', }, }} /> @@ -1072,7 +1097,7 @@ const PRFileDiffViewer: React.FC<{ if (!file.patch) { return ( - + {file.status === 'renamed' ? 'File renamed without changes.' @@ -1082,8 +1107,9 @@ const PRFileDiffViewer: React.FC<{ component="a" href={file.blob_url} target="_blank" + rel="noopener noreferrer" sx={{ - color: STATUS_COLORS.info, + color: 'status.info', fontSize: '0.85rem', textDecoration: 'none', '&:hover': { textDecoration: 'underline' }, @@ -1102,9 +1128,10 @@ const PRFileDiffViewer: React.FC<{ id={`file-${file.sha}`} elevation={0} sx={{ - border: '1px solid #30363d', + border: '1px solid', + borderColor: 'border.light', borderRadius: '6px', - backgroundColor: '#0d1117', + backgroundColor: 'background.paper', overflow: 'hidden', scrollMarginTop: '100px', mb: 3, @@ -1114,8 +1141,8 @@ const PRFileDiffViewer: React.FC<{ defaultExpanded disableGutters sx={{ - backgroundColor: '#161b22', - color: '#c9d1d9', + backgroundColor: 'surface.elevated', + color: 'text.tertiary', boxShadow: 'none', borderRadius: 0, '&:before': { display: 'none' }, @@ -1123,14 +1150,15 @@ const PRFileDiffViewer: React.FC<{ }} > } + expandIcon={} sx={{ - borderBottom: '1px solid #30363d', + borderBottom: '1px solid', + borderColor: 'border.light', minHeight: '48px', position: 'sticky', // STICKY HEADER top: 0, zIndex: 10, - backgroundColor: '#161b22', + backgroundColor: 'surface.elevated', '& .MuiAccordionSummary-content': { display: 'flex', alignItems: 'center', @@ -1150,7 +1178,6 @@ const PRFileDiffViewer: React.FC<{ > )} {copied ? ( - + ) : ( )} @@ -1189,12 +1216,20 @@ const PRFileDiffViewer: React.FC<{ }} > +{file.additions} -{file.deletions} @@ -1204,7 +1239,7 @@ const PRFileDiffViewer: React.FC<{ {viewMode === 'unified' ? ( @@ -1335,7 +1371,7 @@ const PRFilesChanged: React.FC = ({ border: `1px solid ${alpha(STATUS_COLORS.error, 0.3)}`, borderRadius: 2, backgroundColor: alpha(STATUS_COLORS.error, 0.05), - color: STATUS_COLORS.error, + color: 'status.error', textAlign: 'center', }} > @@ -1354,19 +1390,22 @@ const PRFilesChanged: React.FC = ({ top: 24, maxHeight: 'calc(100vh - 100px)', overflowY: 'auto', - backgroundColor: '#0d1117', + backgroundColor: 'background.paper', borderRadius: '8px', - border: '1px solid #30363d', + border: '1px solid', + borderColor: 'border.light', p: 1, display: 'flex', flexDirection: 'column', + ...scrollbarSx, }} > = ({ > Files Changed ({files.length}) @@ -1398,8 +1436,7 @@ const PRFilesChanged: React.FC = ({ Wrap Lines @@ -1419,19 +1456,18 @@ const PRFilesChanged: React.FC = ({ width: '100%', '& .MuiToggleButton-root': { flex: 1, - color: STATUS_COLORS.open, - borderColor: '#30363d', - fontFamily: '"JetBrains Mono", monospace', + color: 'status.open', + borderColor: 'border.light', fontSize: '0.75rem', textTransform: 'none', py: 0.5, '&.Mui-selected': { - color: '#fff', - backgroundColor: 'rgba(56, 139, 253, 0.15)', - borderColor: '#388bfd', + color: 'text.primary', + backgroundColor: selectedFileBackground, + borderColor: 'status.info', }, '&:hover': { - backgroundColor: 'rgba(255,255,255,0.05)', + backgroundColor: 'surface.light', }, }, }} diff --git a/src/components/prs/PRHeader.tsx b/src/components/prs/PRHeader.tsx index b7e9d6dc..caaed585 100644 --- a/src/components/prs/PRHeader.tsx +++ b/src/components/prs/PRHeader.tsx @@ -1,10 +1,11 @@ import React from 'react'; import { Box, Typography, Avatar, Tooltip, alpha } from '@mui/material'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import { useNavigate } from 'react-router-dom'; +import { linkResetSx, useLinkBehavior } from '../common/linkBehavior'; import { formatUsdEstimate } from '../../utils'; import { type PullRequestDetails } from '../../api/models/Dashboard'; -import theme, { STATUS_COLORS } from '../../theme'; +import { STATUS_COLORS } from '../../theme'; +import { getRepositoryOwnerAvatarBackground } from '../leaderboard/types'; interface PRHeaderProps { repository: string; pullRequestNumber: number; @@ -16,25 +17,32 @@ const PRHeader: React.FC = ({ pullRequestNumber, prDetails, }) => { - const navigate = useNavigate(); const [owner] = repository.split('/'); + const repoLinkProps = useLinkBehavior( + `/miners/repository?name=${encodeURIComponent(repository)}`, + { state: { backLabel: `Back to PR #${pullRequestNumber}` } }, + ); const isOpenPR = prDetails.prState === 'OPEN'; const isClosed = prDetails.prState === 'CLOSED'; const collateralScore = parseFloat(prDetails.collateralScore || '0'); const earnedScore = parseFloat(prDetails.earnedScore || '0'); const predictedUsdPerDay = prDetails.predictedUsdPerDay; + const ownerAvatarBackground = getRepositoryOwnerAvatarBackground(owner); + const statusColor = + prDetails.prState === 'CLOSED' + ? STATUS_COLORS.closed + : prDetails.prState === 'MERGED' + ? STATUS_COLORS.merged + : STATUS_COLORS.open; return ( - navigate( - `/miners/repository?name=${encodeURIComponent(repository)}`, - { state: { backLabel: `Back to PR #${pullRequestNumber}` } }, - ) - } + component="a" + {...repoLinkProps} sx={{ + ...linkResetSx, cursor: 'pointer', transition: 'transform 0.2s', '&:hover': { @@ -48,13 +56,9 @@ const PRHeader: React.FC = ({ sx={{ width: 64, height: 64, - border: '2px solid rgba(255, 255, 255, 0.2)', - backgroundColor: - owner === 'opentensor' - ? '#ffffff' - : owner === 'bitcoin' - ? '#F7931A' - : 'transparent', + border: '2px solid', + borderColor: 'border.medium', + backgroundColor: ownerAvatarBackground, }} /> @@ -63,50 +67,39 @@ const PRHeader: React.FC = ({ #{pullRequestNumber} - {(() => { - const statusColor = - prDetails.prState === 'CLOSED' - ? theme.palette.status.closed - : prDetails.prState === 'MERGED' - ? theme.palette.status.merged - : theme.palette.status.open; - return ( - - - {prDetails.prState} - - - ); - })()} + + + {prDetails.prState} + + = ({ - navigate( - `/miners/repository?name=${encodeURIComponent(repository)}`, - { state: { backLabel: `Back to PR #${pullRequestNumber}` } }, - ) - } + component="a" + {...repoLinkProps} sx={{ - color: 'rgba(255, 255, 255, 0.5)', - fontFamily: '"JetBrains Mono", monospace', + ...linkResetSx, + color: 'text.tertiary', fontSize: '0.85rem', cursor: 'pointer', transition: 'color 0.2s', @@ -160,27 +149,26 @@ const PRHeader: React.FC = ({ slotProps={{ tooltip: { sx: { - backgroundColor: 'rgba(30, 30, 30, 0.95)', - color: '#ffffff', + backgroundColor: 'surface.tooltip', + color: 'text.primary', fontSize: '0.75rem', - fontFamily: '"JetBrains Mono", monospace', padding: '8px 12px', borderRadius: '6px', - border: '1px solid rgba(255, 255, 255, 0.1)', + border: '1px solid', + borderColor: 'border.light', maxWidth: 280, }, }, arrow: { sx: { - color: 'rgba(30, 30, 30, 0.95)', + color: 'surface.tooltip', }, }, }} > = ({ {(collateralScore * 5).toFixed(2)} @@ -214,7 +201,7 @@ const PRHeader: React.FC = ({ sx={{ width: '1px', height: '55px', - backgroundColor: 'rgba(255, 255, 255, 0.15)', + backgroundColor: 'border.light', mt: 0.5, }} /> @@ -228,27 +215,26 @@ const PRHeader: React.FC = ({ slotProps={{ tooltip: { sx: { - backgroundColor: 'rgba(30, 30, 30, 0.95)', - color: '#ffffff', + backgroundColor: 'surface.tooltip', + color: 'text.primary', fontSize: '0.75rem', - fontFamily: '"JetBrains Mono", monospace', padding: '8px 12px', borderRadius: '6px', - border: '1px solid rgba(255, 255, 255, 0.1)', + border: '1px solid', + borderColor: 'border.light', maxWidth: 240, }, }, arrow: { sx: { - color: 'rgba(30, 30, 30, 0.95)', + color: 'surface.tooltip', }, }, }} > = ({ 0 - ? 'rgba(248, 113, 113, 0.9)' - : 'rgba(255, 255, 255, 0.4)', + collateralScore > 0 ? 'risk.exceeded' : 'text.secondary', }} > {collateralScore > 0 @@ -287,8 +270,7 @@ const PRHeader: React.FC = ({ = ({ {earnedScore.toFixed(2)} @@ -318,28 +299,28 @@ const PRHeader: React.FC = ({ slotProps={{ tooltip: { sx: { - backgroundColor: 'rgba(30, 30, 30, 0.95)', - color: '#ffffff', + backgroundColor: 'surface.tooltip', + color: 'text.primary', fontSize: '0.75rem', - fontFamily: '"JetBrains Mono", monospace', padding: '8px 12px', borderRadius: '6px', - border: '1px solid rgba(255, 255, 255, 0.1)', + border: '1px solid', + borderColor: 'border.light', maxWidth: 280, }, }, arrow: { sx: { - color: 'rgba(30, 30, 30, 0.95)', + color: 'surface.tooltip', }, }, }} > = ({ filePath, defaultBranch = 'main', }) => { + const theme = useTheme(); const [content, setContent] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -29,6 +38,8 @@ const CodeViewer: React.FC = ({ : ''; useEffect(() => { + const controller = new AbortController(); + const fetchContent = async () => { if (!filePath || isImage) { // Don't fetch text content for images or if no file selected @@ -43,19 +54,23 @@ const CodeViewer: React.FC = ({ // Use raw.githubusercontent.com const response = await axios.get(rawUrl, { transformResponse: [(data) => data], + signal: controller.signal, }); // Force text + if (controller.signal.aborted) return; setContent(response.data); } catch (err) { + if (axios.isCancel(err) || controller.signal.aborted) return; console.error('Failed to fetch file content', err); setError( 'Could not load file content. It might be binary or too large.', ); } finally { - setLoading(false); + if (!controller.signal.aborted) setLoading(false); } }; fetchContent(); + return () => controller.abort(); }, [repositoryFullName, filePath, defaultBranch, isImage, rawUrl]); if (!filePath) { @@ -100,7 +115,7 @@ const CodeViewer: React.FC = ({ display: 'flex', justifyContent: 'center', alignItems: 'center', - backgroundColor: '#0d1117', + backgroundColor: theme.palette.background.default, p: 4, }} > @@ -112,9 +127,9 @@ const CodeViewer: React.FC = ({ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', - border: '1px solid #30363d', + border: `1px solid ${theme.palette.border.light}`, borderRadius: '6px', - backgroundColor: '#161b22', // slight background to see transparent pngs better + backgroundColor: theme.palette.surface.elevated, }} /> @@ -129,25 +144,26 @@ const CodeViewer: React.FC = ({ p: 3, height: '100%', overflow: 'auto', + ...scrollbarSx, '& img': { maxWidth: '100%' }, '& pre': { - backgroundColor: '#1e1e1e', + backgroundColor: theme.palette.surface.tooltip, p: 2, borderRadius: 1, overflowX: 'auto', }, '& code': { fontFamily: 'monospace', - backgroundColor: 'rgba(255,255,255,0.1)', + backgroundColor: alpha(theme.palette.common.white, 0.1), px: 0.5, borderRadius: 0.5, }, '& h1, & h2, & h3': { - color: '#fff', - borderBottom: '1px solid #30363d', + color: theme.palette.text.primary, + borderBottom: `1px solid ${theme.palette.border.light}`, pb: 1, }, - color: '#c9d1d9', + color: theme.palette.text.tertiary, lineHeight: 1.6, }} > @@ -161,9 +177,12 @@ const CodeViewer: React.FC = ({ sx={{ height: '100%', width: '100%', - overflow: 'auto', - backgroundColor: '#1e1e1e', + overflow: 'hidden', + backgroundColor: theme.palette.surface.tooltip, fontSize: '14px', + '& pre': { + ...scrollbarSx, + }, }} > = ({ repositoryFullName, }) => { + const theme = useTheme(); const [content, setContent] = useState(null); const [defaultBranch, setDefaultBranch] = useState('main'); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { + const controller = new AbortController(); + const fetchContributing = async () => { setLoading(true); setError(null); @@ -33,30 +43,34 @@ const ContributingViewer: React.FC = ({ for (const branch of branches) { for (const path of paths) { + if (controller.signal.aborted) return; try { const response = await axios.get( `https://cdn.jsdelivr.net/gh/${repositoryFullName}@${branch}/${path}`, + { signal: controller.signal }, ); + if (controller.signal.aborted) return; if (response.status === 200 && response.data) { setContent(response.data); setDefaultBranch(branch); setLoading(false); return; } - } catch { + } catch (err) { + if (axios.isCancel(err) || controller.signal.aborted) return; // Continue to next combination } } } + if (controller.signal.aborted) return; // If we get here, nothing was found setError('No contributing guidelines found for this repository.'); setLoading(false); }; - if (repositoryFullName) { - fetchContributing(); - } + if (repositoryFullName) fetchContributing(); + return () => controller.abort(); }, [repositoryFullName]); if (loading) { @@ -72,9 +86,9 @@ const ContributingViewer: React.FC = ({ {error} @@ -83,96 +97,7 @@ const ContributingViewer: React.FC = ({ } return ( - + void; selectedFile: string | null; }> = ({ node, level, onSelect, selectedFile }) => { + const theme = useTheme(); const [open, setOpen] = useState(false); const isSelected = selectedFile === node.path; + const accent = theme.palette.status.info; const handleClick = () => { if (node.type === 'tree') { @@ -68,16 +72,14 @@ const FileItem: React.FC<{ py: 0.25, minHeight: 24, height: 24, - backgroundColor: isSelected - ? 'rgba(56, 139, 253, 0.15)' - : 'transparent', + backgroundColor: isSelected ? alpha(accent, 0.15) : 'transparent', borderLeft: isSelected - ? '2px solid #388bfd' + ? `2px solid ${accent}` : '2px solid transparent', '&:hover': { backgroundColor: isSelected - ? 'rgba(56, 139, 253, 0.15)' - : 'rgba(255, 255, 255, 0.04)', + ? alpha(accent, 0.15) + : alpha(theme.palette.common.white, 0.04), }, transition: 'all 0.1s ease-in-out', }} @@ -123,7 +125,9 @@ const FileItem: React.FC<{ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', fontSize: '13px', - color: isSelected ? '#fff' : STATUS_COLORS.open, + color: isSelected + ? theme.palette.text.primary + : STATUS_COLORS.open, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', @@ -220,6 +224,7 @@ const FileExplorer: React.FC = ({ height: '100%', overflowY: 'auto', overflowX: 'hidden', + ...scrollbarSx, }} > diff --git a/src/components/repositories/LanguageWeightsTable.tsx b/src/components/repositories/LanguageWeightsTable.tsx index 7cb3156c..95a242a5 100644 --- a/src/components/repositories/LanguageWeightsTable.tsx +++ b/src/components/repositories/LanguageWeightsTable.tsx @@ -20,18 +20,26 @@ import { IconButton, Collapse, Tooltip, + alpha, + useTheme, } from '@mui/material'; import { Search, Check, Close } from '@mui/icons-material'; import ReactECharts from 'echarts-for-react'; import BarChartIcon from '@mui/icons-material/BarChart'; import TableChartIcon from '@mui/icons-material/TableChart'; -import theme from '../../theme'; +import { + scrollbarSx, + TEXT_OPACITY, + headerCellStyle, + bodyCellStyle, +} from '../../theme'; import { useLanguagesAndWeights } from '../../api'; type SortField = 'extension' | 'weight' | 'language'; type SortOrder = 'asc' | 'desc'; const LanguageWeightsTable: React.FC = () => { + const theme = useTheme(); const { data: languages, isLoading } = useLanguagesAndWeights(); const [searchQuery, setSearchQuery] = useState(''); const [sortField, setSortField] = useState('weight'); @@ -113,10 +121,10 @@ const LanguageWeightsTable: React.FC = () => { return filteredAndSortedLanguages.slice(startIndex, endIndex); }, [filteredAndSortedLanguages, page, rowsPerPage]); - const getChartOption = () => { + const chartOption = useMemo(() => { const chartData = filteredAndSortedLanguages; - const textColor = 'rgba(255, 255, 255, 0.85)'; - const gridColor = 'rgba(255, 255, 255, 0.08)'; + const textColor = alpha(theme.palette.common.white, 0.85); + const gridColor = theme.palette.border.subtle; const xAxisData = chartData.map((item) => item.extension); const seriesData = chartData.map((item) => { @@ -132,24 +140,24 @@ const LanguageWeightsTable: React.FC = () => { left: 'center', top: 20, textStyle: { - color: '#ffffff', - fontFamily: 'JetBrains Mono', + color: theme.palette.text.primary, fontSize: 18, fontWeight: 600, }, subtextStyle: { - color: 'rgba(255, 255, 255, 0.5)', - fontFamily: 'JetBrains Mono', + color: alpha(theme.palette.common.white, TEXT_OPACITY.tertiary), fontSize: 12, }, }, tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' }, - backgroundColor: 'rgba(15, 15, 18, 0.95)', - borderColor: 'rgba(255, 255, 255, 0.15)', + backgroundColor: alpha(theme.palette.background.default, 0.95), + borderColor: alpha(theme.palette.common.white, 0.15), borderWidth: 1, - textStyle: { color: '#fff', fontFamily: 'JetBrains Mono' }, + textStyle: { + color: theme.palette.text.primary, + }, }, grid: { left: '3%', @@ -163,7 +171,6 @@ const LanguageWeightsTable: React.FC = () => { data: xAxisData, axisLabel: { color: textColor, - fontFamily: 'JetBrains Mono', rotate: 45, interval: 0, }, @@ -172,8 +179,6 @@ const LanguageWeightsTable: React.FC = () => { yAxis: { type: 'value', name: 'Weight', - nameTextStyle: { color: textColor, fontFamily: 'JetBrains Mono' }, - axisLabel: { color: textColor, fontFamily: 'JetBrains Mono' }, splitLine: { lineStyle: { color: gridColor, type: 'dashed' } }, }, series: [ @@ -188,8 +193,8 @@ const LanguageWeightsTable: React.FC = () => { x2: 0, y2: 1, colorStops: [ - { offset: 0, color: '#3f51b5' }, - { offset: 1, color: '#2196f3' }, + { offset: 0, color: theme.palette.primary.main }, + { offset: 1, color: theme.palette.status.info }, ], }, borderRadius: [4, 4, 0, 0], @@ -197,7 +202,7 @@ const LanguageWeightsTable: React.FC = () => { }, ], }; - }; + }, [filteredAndSortedLanguages, theme]); // Scroll to top when rows per page changes useEffect(() => { @@ -232,13 +237,15 @@ const LanguageWeightsTable: React.FC = () => { onClick={() => setShowChart(!showChart)} size="small" sx={{ - color: showChart ? '#ffffff' : 'rgba(255, 255, 255, 0.5)', - border: '1px solid rgba(255, 255, 255, 0.1)', + color: showChart + ? theme.palette.text.primary + : alpha(theme.palette.common.white, TEXT_OPACITY.muted), + border: `1px solid ${theme.palette.border.light}`, borderRadius: 2, padding: '6px', '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.05)', - borderColor: 'rgba(255, 255, 255, 0.2)', + backgroundColor: theme.palette.surface.subtle, + borderColor: theme.palette.border.medium, }, }} > @@ -254,8 +261,10 @@ const LanguageWeightsTable: React.FC = () => { @@ -268,16 +277,15 @@ const LanguageWeightsTable: React.FC = () => { setPage(0); }} sx={{ - color: '#ffffff', - fontFamily: '"JetBrains Mono", monospace', - backgroundColor: 'rgba(0, 0, 0, 0.4)', + color: theme.palette.text.primary, + backgroundColor: alpha(theme.palette.common.black, 0.4), fontSize: '0.8rem', height: '36px', borderRadius: 2, minWidth: '80px', - '& fieldset': { borderColor: 'rgba(255, 255, 255, 0.1)' }, + '& fieldset': { borderColor: theme.palette.border.light }, '&:hover fieldset': { - borderColor: 'rgba(255, 255, 255, 0.2)', + borderColor: theme.palette.border.medium, }, '&.Mui-focused fieldset': { borderColor: 'primary.main' }, '& .MuiSelect-select': { @@ -301,7 +309,13 @@ const LanguageWeightsTable: React.FC = () => { startAdornment: ( ), @@ -309,14 +323,15 @@ const LanguageWeightsTable: React.FC = () => { sx={{ width: '200px', '& .MuiOutlinedInput-root': { - color: '#ffffff', - fontFamily: '"JetBrains Mono", monospace', - backgroundColor: 'rgba(0, 0, 0, 0.4)', + color: theme.palette.text.primary, + backgroundColor: alpha(theme.palette.common.black, 0.4), fontSize: '0.8rem', height: '36px', borderRadius: 2, - '& fieldset': { borderColor: 'rgba(255, 255, 255, 0.1)' }, - '&:hover fieldset': { borderColor: 'rgba(255, 255, 255, 0.2)' }, + '& fieldset': { borderColor: theme.palette.border.light }, + '&:hover fieldset': { + borderColor: theme.palette.border.medium, + }, '&.Mui-focused fieldset': { borderColor: 'primary.main' }, }, }} @@ -328,14 +343,14 @@ const LanguageWeightsTable: React.FC = () => { {showChart && filteredAndSortedLanguages.length > 0 && ( )} @@ -354,31 +369,13 @@ const LanguageWeightsTable: React.FC = () => { backgroundColor: 'transparent', maxHeight: '800px', overflowY: 'auto', - '&::-webkit-scrollbar': { - width: '8px', - }, - '&::-webkit-scrollbar-track': { - backgroundColor: 'transparent', - }, - '&::-webkit-scrollbar-thumb': { - backgroundColor: 'rgba(255, 255, 255, 0.1)', - borderRadius: '4px', - '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.2)', - }, - }, + ...scrollbarSx, }} >
- + { }, }} > - Extension + Extension - + { }, }} > - Language + Language - + - - Token Scoring - + Token Scoring - + { }, }} > - Weight + Weight @@ -461,22 +436,16 @@ const LanguageWeightsTable: React.FC = () => { {paginatedLanguages.map((lang) => ( - - - {lang.extension} - - - - - {lang.language || '-'} - + {lang.extension} + + {lang.language || '-'} - + {lang.language ? ( { /> )} - - {lang.weight} + + {lang.weight} ))} @@ -514,9 +483,7 @@ const LanguageWeightsTable: React.FC = () => { showFirstButton showLastButton sx={{ - '.MuiTablePagination-displayedRows': { - fontFamily: '"JetBrains Mono", monospace', - }, + '.MuiTablePagination-displayedRows': {}, }} /> diff --git a/src/components/repositories/ReadmeViewer.tsx b/src/components/repositories/ReadmeViewer.tsx index bd66af6d..3370bbe8 100644 --- a/src/components/repositories/ReadmeViewer.tsx +++ b/src/components/repositories/ReadmeViewer.tsx @@ -1,23 +1,33 @@ import React, { useState, useEffect } from 'react'; -import { Box, CircularProgress, Alert, Paper } from '@mui/material'; +import { + Box, + CircularProgress, + Alert, + Paper, + alpha, + useTheme, +} from '@mui/material'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import axios from 'axios'; -import { STATUS_COLORS } from '../../theme'; import { resolveRelativeUrl } from './MarkdownRenderers'; +import { markdownDocumentPaperSx } from '../../theme'; interface ReadmeViewerProps { repositoryFullName: string; // e.g., "opentensor/bittensor" } const ReadmeViewer: React.FC = ({ repositoryFullName }) => { + const theme = useTheme(); const [content, setContent] = useState(null); const [defaultBranch, setDefaultBranch] = useState('main'); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { + const controller = new AbortController(); + const fetchReadme = async () => { setLoading(true); setError(null); @@ -26,28 +36,33 @@ const ReadmeViewer: React.FC = ({ repositoryFullName }) => { try { const response = await axios.get( `https://cdn.jsdelivr.net/gh/${repositoryFullName}@main/README.md`, + { signal: controller.signal }, ); + if (controller.signal.aborted) return; setContent(response.data); setDefaultBranch('main'); - } catch { + } catch (err) { + if (axios.isCancel(err) || controller.signal.aborted) return; // Fallback to 'master' branch const response = await axios.get( `https://cdn.jsdelivr.net/gh/${repositoryFullName}@master/README.md`, + { signal: controller.signal }, ); + if (controller.signal.aborted) return; setContent(response.data); setDefaultBranch('master'); } } catch (err) { + if (axios.isCancel(err) || controller.signal.aborted) return; console.error('Failed to fetch README', err); setError('Could not load README.md'); } finally { - setLoading(false); + if (!controller.signal.aborted) setLoading(false); } }; - if (repositoryFullName) { - fetchReadme(); - } + if (repositoryFullName) fetchReadme(); + return () => controller.abort(); }, [repositoryFullName]); if (loading) { @@ -62,7 +77,10 @@ const ReadmeViewer: React.FC = ({ repositoryFullName }) => { return ( {error} @@ -70,111 +88,7 @@ const ReadmeViewer: React.FC = ({ repositoryFullName }) => { } return ( - + = ({ repositoryFullName, }) => { + const theme = useTheme(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [repoData, setRepoData] = useState(null); @@ -93,9 +97,8 @@ const RepositoryCheckTab: React.FC = ({ setHelpWantedCount(hwRes.data.total_count); } catch (e) { console.warn('Failed to fetch issue counts', e); - // Fallback to repoData count if search fails, though it includes PRs - if (repoData && repoData.open_issues_count !== undefined) { - setOpenIssuesCount(repoData.open_issues_count); + if (repoRes.data?.open_issues_count !== undefined) { + setOpenIssuesCount(repoRes.data.open_issues_count); } } } catch (err: unknown) { @@ -109,7 +112,6 @@ const RepositoryCheckTab: React.FC = ({ if (repositoryFullName) { fetchData(); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [repositoryFullName]); const checks: HealthCheck[] = useMemo(() => { @@ -190,7 +192,7 @@ const RepositoryCheckTab: React.FC = ({ Repository Health Check & Feasibility @@ -215,8 +217,8 @@ const RepositoryCheckTab: React.FC = ({ = ({ sx={{ color: score > 80 - ? '#238636' + ? STATUS_COLORS.success : score > 50 - ? '#e3b341' - : '#da3633', + ? STATUS_COLORS.warning + : STATUS_COLORS.error, }} /> = ({ {score}% @@ -273,7 +274,7 @@ const RepositoryCheckTab: React.FC = ({ Health Score @@ -293,8 +294,8 @@ const RepositoryCheckTab: React.FC = ({ = ({ = ({ - {new Date(repoData.pushed_at).toLocaleDateString()} + {formatDate(repoData.pushed_at)} @@ -342,12 +342,11 @@ const RepositoryCheckTab: React.FC = ({ - {new Date(repoData.created_at).toLocaleDateString()} + {formatDate(repoData.created_at)} @@ -366,12 +365,12 @@ const RepositoryCheckTab: React.FC = ({ bgcolor: repoData.archived ? 'error.dark' : 'success.dark', - color: '#fff', + color: 'text.primary', }} /> - + = ({ @@ -411,7 +410,7 @@ const RepositoryCheckTab: React.FC = ({ = ({ - {/* Stat: Open Issues */} - + {/* Stat: Open Issues — 2×2 from md until lg so link cards have room; 4 across on lg+ */} + = ({ = ({ {/* Stat: Forks */} - + = ({ = ({ {/* Action: Good First Issues */} - + = ({ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', + gap: 1, mb: 1, + minWidth: 0, }} > - + = ({ color: STATUS_COLORS.open, fontSize: '12px', fontWeight: 600, - whiteSpace: 'nowrap', + lineHeight: 1.25, + wordBreak: 'break-word', }} > Good First Issues Perfect for beginners @@ -566,26 +574,30 @@ const RepositoryCheckTab: React.FC = ({ {/* Action: Help Wanted */} - + = ({ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', + gap: 1, mb: 1, + minWidth: 0, }} > - + = ({ color: STATUS_COLORS.open, fontSize: '12px', fontWeight: 600, - whiteSpace: 'nowrap', + lineHeight: 1.25, + wordBreak: 'break-word', }} > Help Wanted General contributions @@ -640,8 +659,8 @@ const RepositoryCheckTab: React.FC = ({ = ({ = ({ sx={{ p: 2, borderRadius: 1, - bgcolor: 'rgba(255,255,255,0.02)', - border: '1px solid rgba(255,255,255,0.05)', + bgcolor: 'surface.subtle', + border: `1px solid ${alpha(theme.palette.common.white, 0.05)}`, display: 'flex', alignItems: 'flex-start', gap: 2, height: '100%', transition: 'background-color 0.2s', '&:hover': { - bgcolor: 'rgba(255,255,255,0.04)', + bgcolor: alpha(theme.palette.common.white, 0.04), }, }} > {check.passed ? ( ) : ( )} = ({ repositoryFullName, }) => { + const theme = useTheme(); const [tree, setTree] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -104,14 +190,28 @@ const RepositoryCodeBrowser: React.FC = ({ ); if (response.data && response.data.length > 0) { - const c = response.data[0]; + const listCommit = response.data[0]; + const c = await fetchCommitPayload(repositoryFullName, listCommit); + const commit = c.commit as { + message: string; + committer?: { name?: string; email?: string; date?: string }; + author?: { date?: string }; + }; + const ghCommitter = c.committer as GhCommitUser | null | undefined; + const ghAuthor = c.author as GhCommitUser | null | undefined; + const { login: committerLogin, avatarUrl } = + resolveGithubCommitAttribution(ghCommitter, ghAuthor, commit); + const date = + isMechanicalCommitter(ghCommitter?.login) && ghAuthor?.login + ? commit.author?.date || commit.committer?.date || '' + : commit.committer?.date || commit.author?.date || ''; setPathCommits((prev) => ({ ...prev, [pathKey]: { - message: c.commit.message, - author: c.commit.author.name, - avatarUrl: c.author?.avatar_url || '', - date: c.commit.author.date, + message: commit.message, + committerLogin, + avatarUrl, + date, sha: c.sha.substring(0, 7), }, })); @@ -205,7 +305,9 @@ const RepositoryCodeBrowser: React.FC = ({ onClick={() => handleNavigate(null)} sx={{ fontWeight: !currentPath ? 600 : 400, - color: !currentPath ? '#c9d1d9' : STATUS_COLORS.info, + color: !currentPath + ? theme.palette.text.tertiary + : STATUS_COLORS.info, cursor: !currentPath ? 'default' : 'pointer', fontSize: '14px', }} @@ -224,7 +326,9 @@ const RepositoryCodeBrowser: React.FC = ({ onClick={() => !isLast && handleNavigate(path)} sx={{ fontWeight: isLast ? 600 : 400, - color: isLast ? '#c9d1d9' : STATUS_COLORS.info, + color: isLast + ? theme.palette.text.tertiary + : STATUS_COLORS.info, cursor: isLast ? 'default' : 'pointer', fontSize: '14px', }} @@ -241,10 +345,10 @@ const RepositoryCodeBrowser: React.FC = ({ = ({ sx={{ fontSize: '13px', fontWeight: 600, - color: '#c9d1d9', + color: theme.palette.text.tertiary, whiteSpace: 'nowrap', }} > - {currentCommit.author} + {currentCommit.committerLogin} = ({ sx={{ fontSize: '13px', color: STATUS_COLORS.open, - fontFamily: '"JetBrains Mono", monospace', }} > {currentCommit.sha} @@ -341,9 +444,9 @@ const RepositoryCodeBrowser: React.FC = ({ component={Paper} elevation={0} sx={{ - border: '1px solid #30363d', + border: `1px solid ${theme.palette.border.light}`, borderRadius: isFile ? '6px' : '0 0 6px 6px', // Connect to header - backgroundColor: '#0d1117', + backgroundColor: theme.palette.background.paper, }} >
@@ -353,7 +456,9 @@ const RepositoryCodeBrowser: React.FC = ({ @@ -368,7 +473,7 @@ const RepositoryCodeBrowser: React.FC = ({ }} sx={{ color: STATUS_COLORS.info, - borderBottom: '1px solid #21262d', + borderBottom: `1px solid ${theme.palette.border.subtle}`, py: 1, fontSize: '13px', fontWeight: 600, @@ -384,21 +489,25 @@ const RepositoryCodeBrowser: React.FC = ({ hover onClick={() => handleNavigate(node.path)} sx={{ - '&:hover': { backgroundColor: '#161b22' }, + '&:hover': { + backgroundColor: theme.palette.surface.elevated, + }, cursor: 'pointer', transition: 'background-color 0.1s', }} > {node.type === 'tree' ? ( - + ) : ( = ({ = ({ = ({ repositoryFullName }) => { - const navigate = useNavigate(); + const theme = useTheme(); const { data: allPRs, isLoading } = useAllPrs(); const { data: allMinersStats } = useAllMiners(); @@ -45,7 +53,7 @@ const RepositoryContributorsTable: React.FC< (pr) => pr.repository.toLowerCase() === repositoryFullName.toLowerCase() && pr.githubId && - pr.prState === 'MERGED', + isMergedPr(pr), ); const contributorsMap = new Map< @@ -112,7 +120,6 @@ const RepositoryContributorsTable: React.FC< variant="subtitle2" sx={{ color: 'text.secondary', - fontFamily: '"JetBrains Mono", monospace', }} > Top Miner Contributors{' '} @@ -125,15 +132,18 @@ const RepositoryContributorsTable: React.FC< - {/* Header Row */} + {/* Header Row — minmax(0,1fr) prevents the miner column from forcing PRS/SCORE off-alignment */} @@ -147,6 +157,7 @@ const RepositoryContributorsTable: React.FC< fontSize: '11px', color: 'text.secondary', textAlign: 'right', + fontVariantNumeric: 'tabular-nums', }} > PRS @@ -156,6 +167,7 @@ const RepositoryContributorsTable: React.FC< fontSize: '11px', color: 'text.secondary', textAlign: 'right', + fontVariantNumeric: 'tabular-nums', }} > SCORE @@ -174,15 +186,18 @@ const RepositoryContributorsTable: React.FC< key={contributor.githubId} sx={{ display: 'grid', - gridTemplateColumns: '32px 1fr 48px 75px', - gap: 1, + gridTemplateColumns: + '32px minmax(0, 1fr) minmax(3rem, auto) minmax(4.5rem, auto)', + columnGap: 1, + rowGap: 0, px: 1.5, py: 1, - borderBottom: '1px solid rgba(255,255,255,0.05)', + borderBottom: `1px solid ${alpha(theme.palette.common.white, 0.05)}`, alignItems: 'center', + minWidth: 0, opacity: isInactive ? 0.5 : 1, '&:hover': { - backgroundColor: 'rgba(255,255,255,0.04)', + backgroundColor: alpha(theme.palette.common.white, 0.04), opacity: 1, }, transition: 'all 0.1s', @@ -191,9 +206,8 @@ const RepositoryContributorsTable: React.FC< {/* Rank */} @@ -201,12 +215,11 @@ const RepositoryContributorsTable: React.FC< {/* Contributor */} - - navigate(`/miners/details?githubId=${contributor.githubId}`, { - state: { backLabel: `Back to ${repositoryFullName}` }, - }) - } + )} - + {/* PRs */} {contributor.prs} @@ -284,8 +298,9 @@ const RepositoryContributorsTable: React.FC< sx={{ textAlign: 'right', fontSize: '12px', - color: '#c9d1d9', - fontFamily: '"JetBrains Mono", monospace', + color: 'text.primary', + fontVariantNumeric: 'tabular-nums', + minWidth: 0, }} > {contributor.score.toFixed(2)} @@ -308,8 +323,8 @@ const RepositoryContributorsTable: React.FC< color: STATUS_COLORS.open, fontSize: '12px', '&:hover': { - color: '#fff', - backgroundColor: 'rgba(255,255,255,0.02)', + color: 'text.primary', + backgroundColor: 'surface.subtle', }, transition: 'all 0.1s', }} diff --git a/src/components/repositories/RepositoryDetails.tsx b/src/components/repositories/RepositoryDetails.tsx deleted file mode 100644 index ed178dff..00000000 --- a/src/components/repositories/RepositoryDetails.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import RepositoryScoreCard from './RepositoryScoreCard'; -import RepositoryContributorsTable from './RepositoryContributorsTable'; -import RepositoryPRsTable from './RepositoryPRsTable'; - -interface RepositoryDetailsProps { - repositoryFullName: string; -} - -const RepositoryDetails: React.FC = ({ - repositoryFullName, -}) => ( - <> - - - - -); - -export default RepositoryDetails; diff --git a/src/components/repositories/RepositoryIssuesTable.tsx b/src/components/repositories/RepositoryIssuesTable.tsx index 12fc8377..931bf2d2 100644 --- a/src/components/repositories/RepositoryIssuesTable.tsx +++ b/src/components/repositories/RepositoryIssuesTable.tsx @@ -1,27 +1,34 @@ -import React, { useMemo, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import React, { useCallback, useMemo, useState } from 'react'; import { - Card, - Typography, Box, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - CircularProgress, + Card, Chip, - Button, + CircularProgress, Stack, + Typography, + alpha, + useTheme, } from '@mui/material'; -import { useRepositoryIssues, useRepoIssues } from '../../api'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked'; - import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import { + useRepositoryIssues, + useRepoIssues, + type RepositoryIssue, +} from '../../api'; +import { LinkBox } from '../common/linkBehavior'; +import { + DataTable, + type DataTableColumn, +} from '../../components/common/DataTable'; import { formatTokenAmount } from '../../utils/format'; -import { STATUS_COLORS } from '../../theme'; +import { + getIssueStatusMeta, + getBountyAmountColor, +} from '../../utils/issueStatus'; +import { STATUS_COLORS, TEXT_OPACITY, scrollbarSx } from '../../theme'; +import FilterButton from '../FilterButton'; interface RepositoryIssuesTableProps { repositoryFullName: string; @@ -30,7 +37,7 @@ interface RepositoryIssuesTableProps { const RepositoryIssuesTable: React.FC = ({ repositoryFullName, }) => { - const navigate = useNavigate(); + const theme = useTheme(); const { data: issues, isLoading } = useRepositoryIssues(repositoryFullName); const { data: bounties } = useRepoIssues(repositoryFullName); const [filter, setFilter] = useState<'all' | 'open' | 'closed'>('all'); @@ -46,7 +53,6 @@ const RepositoryIssuesTable: React.FC = ({ const filteredIssues = useMemo(() => { if (!issues) return []; - if (filter === 'all') return issues; if (filter === 'open') return issues.filter((issue) => !issue.closedAt); if (filter === 'closed') return issues.filter((issue) => issue.closedAt); return issues; @@ -55,7 +61,7 @@ const RepositoryIssuesTable: React.FC = ({ const sortedIssues = useMemo( () => [...filteredIssues].sort((a, b) => { - // Sort by creation date, most recent first + // Sort by creation date, most recent first. const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0; const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0; return dateB - dateA; @@ -63,52 +69,22 @@ const RepositoryIssuesTable: React.FC = ({ [filteredIssues], ); - const FilterButton = ({ - label, - value, - count, - color, - }: { - label: string; - value: typeof filter; - count?: number; - color: string; - }) => ( - - ); + const handleRowClick = useCallback((issue: RepositoryIssue) => { + // Row navigates to GitHub in a new tab; using onRowClick (not getRowHref) + // keeps nested cells valid HTML. + window.open( + `https://github.com/${issue.repositoryFullName}/issues/${issue.number}`, + '_blank', + 'noopener,noreferrer', + ); + }, []); if (isLoading) { return ( = ({ elevation={0} > - + Issues @@ -131,455 +101,340 @@ const RepositoryIssuesTable: React.FC = ({ ); } + const columns: DataTableColumn[] = [ + { + key: 'number', + header: 'Issue #', + renderCell: (issue) => ( + e.stopPropagation()} + > + #{issue.number} + + ), + }, + { + key: 'title', + header: 'Title', + renderCell: (issue) => ( + + {issue.title} + + ), + }, + { + key: 'status', + header: 'Status', + renderCell: (issue) => { + const isOpen = !issue.closedAt; + return ( + : } + label={isOpen ? 'OPEN' : 'CLOSED'} + sx={{ + color: isOpen ? STATUS_COLORS.open : STATUS_COLORS.merged, + borderColor: isOpen ? STATUS_COLORS.open : STATUS_COLORS.merged, + '& .MuiChip-icon': { color: 'inherit' }, + }} + /> + ); + }, + }, + { + key: 'linkedPr', + header: 'Linked PR', + renderCell: (issue) => + issue.prNumber ? ( + e.stopPropagation()} + > + #{issue.prNumber} + + ) : ( + + - + + ), + }, + { + key: 'created', + header: 'Created', + align: 'right', + renderCell: (issue) => + issue.createdAt ? new Date(issue.createdAt).toLocaleDateString() : '-', + }, + { + key: 'closed', + header: 'Closed', + align: 'right', + renderCell: (issue) => + issue.closedAt ? new Date(issue.closedAt).toLocaleDateString() : '-', + }, + ]; + + const headerToolbar = ( + + + Issues ({sortedIssues.length}) + + + setFilter('all')} + count={counts.total} + color={STATUS_COLORS.open} + activeTextColor="text.primary" + /> + setFilter('open')} + count={counts.open} + color={STATUS_COLORS.open} + activeTextColor="text.primary" + /> + setFilter('closed')} + count={counts.closed} + color={STATUS_COLORS.merged} + activeTextColor="text.primary" + /> + + + ); + return ( - {/* Bounties Section */} - {bounties && - bounties.length > 0 && - (() => { - const getStatusColor = (status: string) => { - switch (status) { - case 'active': - return { - bg: 'rgba(88, 166, 255, 0.15)', - border: 'rgba(88, 166, 255, 0.4)', - text: STATUS_COLORS.info, - }; - case 'completed': - return { - bg: 'rgba(63, 185, 80, 0.15)', - border: 'rgba(63, 185, 80, 0.4)', - text: STATUS_COLORS.merged, - }; - case 'registered': - return { - bg: 'rgba(245, 158, 11, 0.15)', - border: 'rgba(245, 158, 11, 0.4)', - text: STATUS_COLORS.warning, - }; - case 'cancelled': - return { - bg: 'rgba(239, 68, 68, 0.15)', - border: 'rgba(239, 68, 68, 0.4)', - text: STATUS_COLORS.error, - }; - default: - return { - bg: 'rgba(139, 148, 158, 0.15)', - border: 'rgba(139, 148, 158, 0.4)', - text: STATUS_COLORS.open, - }; - } - }; - const getStatusLabel = (status: string) => { - switch (status) { - case 'active': - return 'Available'; - case 'completed': - return 'Completed'; - case 'registered': - return 'Pending'; - case 'cancelled': - return 'Cancelled'; - default: - return status; - } - }; - const getBountyAmountColor = (status: string) => { - switch (status) { - case 'active': - return STATUS_COLORS.merged; - case 'registered': - return STATUS_COLORS.warning; - case 'completed': - return STATUS_COLORS.merged; - case 'cancelled': - return 'rgba(255, 255, 255, 0.4)'; - default: - return 'rgba(255, 255, 255, 0.6)'; - } - }; - return ( - 0 && ( + + + - - + + + {bounties.map((bounty) => { + const meta = getIssueStatusMeta(bounty.status); + return ( + - Bounties ({bounties.length}) - - - - {bounties.map((bounty) => { - const statusColors = getStatusColor(bounty.status); - return ( - - navigate(`/bounties/details?id=${bounty.id}`, { - state: { backLabel: `Back to ${repositoryFullName}` }, - }) - } + + + + #{bounty.issueNumber} + + + {issues?.find( + (i) => + i.number === bounty.issueNumber && + i.repositoryFullName === bounty.repositoryFullName, + )?.title || `${repositoryFullName}#${bounty.issueNumber}`} + + + + - - - - #{bounty.issueNumber} - - - {issues?.find( - (i) => - i.number === bounty.issueNumber && - i.repositoryFullName === - bounty.repositoryFullName, - )?.title || - `${repositoryFullName}#${bounty.issueNumber}`} - - - - - {`${formatTokenAmount(bounty.targetBounty)} ل`} - - - - - ); - })} - - - ); - })()} + {`${formatTokenAmount(bounty.targetBounty)} ل`} + + + + + ); + })} + + + )} - {/* GitHub Issues Table */} + {/* Issues Table */} - - - Issues ({sortedIssues.length}) - - - - - - - - - - {sortedIssues.length === 0 ? ( - - - No issues found - - - ) : ( - -
- - - Issue # - Title - Status - Linked PR - - Created - - - Closed - - - - - {sortedIssues.map((issue, index) => { - const isOpen = !issue.closedAt; - return ( - { - window.open( - `https://github.com/${issue.repositoryFullName}/issues/${issue.number}`, - '_blank', - ); - }} - > - - e.stopPropagation()} - > - #{issue.number} - - - - - {issue.title} - - - - - ) : ( - - ) - } - label={isOpen ? 'OPEN' : 'CLOSED'} - sx={{ - color: isOpen - ? STATUS_COLORS.open - : STATUS_COLORS.merged, - borderColor: isOpen - ? STATUS_COLORS.open - : STATUS_COLORS.merged, - '& .MuiChip-icon': { color: 'inherit' }, - }} - /> - - - {issue.prNumber ? ( - e.stopPropagation()} - > - #{issue.prNumber} - - ) : ( - - - - - )} - - - {issue.createdAt - ? new Date(issue.createdAt).toLocaleDateString() - : '-'} - - - {issue.closedAt - ? new Date(issue.closedAt).toLocaleDateString() - : '-'} - - - ); - })} - -
- - )} + + columns={columns} + rows={sortedIssues} + getRowKey={(issue) => `${issue.number}-${issue.repositoryFullName}`} + stickyHeader + size="medium" + header={headerToolbar} + emptyState={ + + + No issues found + + + } + onRowClick={handleRowClick} + />
); }; -const headerCellStyle = { - backgroundColor: 'rgba(18, 18, 20, 0.95)', - backdropFilter: 'blur(8px)', - color: 'rgba(255, 255, 255, 0.7)', - fontFamily: '"JetBrains Mono", monospace', - fontWeight: 500, - fontSize: '0.75rem', - borderBottom: '1px solid rgba(255, 255, 255, 0.1)', - textTransform: 'uppercase' as const, - letterSpacing: '0.5px', -}; - -const bodyCellStyle = { - color: 'text.primary', - fontFamily: '"JetBrains Mono", monospace', - borderBottom: '1px solid rgba(255, 255, 255, 0.1)', - fontSize: '0.85rem', -}; - export default RepositoryIssuesTable; diff --git a/src/components/repositories/RepositoryMaintainers.tsx b/src/components/repositories/RepositoryMaintainers.tsx index 38c2bdc0..c71d2463 100644 --- a/src/components/repositories/RepositoryMaintainers.tsx +++ b/src/components/repositories/RepositoryMaintainers.tsx @@ -6,6 +6,8 @@ import { Tooltip, Link, Skeleton, + alpha, + useTheme, } from '@mui/material'; import { useRepositoryMaintainers } from '../../api'; import { STATUS_COLORS } from '../../theme'; @@ -17,6 +19,7 @@ interface RepositoryMaintainersProps { const RepositoryMaintainers: React.FC = ({ repositoryFullName, }) => { + const theme = useTheme(); const { data: maintainers, isLoading } = useRepositoryMaintainers(repositoryFullName); @@ -27,7 +30,6 @@ const RepositoryMaintainers: React.FC = ({ variant="subtitle2" sx={{ color: 'text.secondary', - fontFamily: '"JetBrains Mono", monospace', mb: 2, }} > @@ -52,7 +54,6 @@ const RepositoryMaintainers: React.FC = ({ variant="subtitle2" sx={{ color: 'text.secondary', - fontFamily: '"JetBrains Mono", monospace', mb: 2, }} > @@ -85,7 +86,7 @@ const RepositoryMaintainers: React.FC = ({ '&:hover': { transform: 'scale(1.1)', borderColor: 'primary.main', - boxShadow: '0 0 8px rgba(247, 129, 102, 0.4)', + boxShadow: `0 0 8px ${alpha(theme.palette.primary.main, 0.4)}`, }, }} /> diff --git a/src/components/repositories/RepositoryPRsTable.tsx b/src/components/repositories/RepositoryPRsTable.tsx index 047f6e0b..efdaa20a 100644 --- a/src/components/repositories/RepositoryPRsTable.tsx +++ b/src/components/repositories/RepositoryPRsTable.tsx @@ -1,23 +1,34 @@ -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { - Card, - Typography, - Box, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - CircularProgress, Avatar, + Box, + Card, Chip, + CircularProgress, Stack, - Button, + Typography, + alpha, } from '@mui/material'; -import { useAllPrs } from '../../api'; import { useNavigate } from 'react-router-dom'; -import theme from '../../theme'; +import { useAllPrs, type CommitLog } from '../../api'; +import { + DataTable, + type DataTableColumn, +} from '../../components/common/DataTable'; +import theme, { TEXT_OPACITY, scrollbarSx } from '../../theme'; +import { filterPrs, getPrStatusCounts, type PrStatusFilter } from '../../utils'; +import FilterButton from '../FilterButton'; + +type PrSortField = + | 'pullRequestNumber' + | 'pullRequestTitle' + | 'author' + | 'commitCount' + | 'lines' + | 'score' + | 'status' + | 'mergedAt'; +type SortOrder = 'asc' | 'desc'; interface RepositoryPRsTableProps { repositoryFullName: string; @@ -29,12 +40,22 @@ const RepositoryPRsTable: React.FC = ({ state = 'all', }) => { const navigate = useNavigate(); - const [filter, setFilter] = useState<'all' | 'open' | 'closed' | 'merged'>( - state, - ); + const [filter, setFilter] = useState(state); + const [sortField, setSortField] = useState('score'); + const [sortOrder, setSortOrder] = useState('desc'); + + const handleSort = (field: PrSortField) => { + if (sortField === field) { + setSortOrder((o) => (o === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortOrder( + field === 'pullRequestTitle' || field === 'author' ? 'asc' : 'desc', + ); + } + }; - // Fetch ALL PRs at once to enable client-side filtering and accurate counts - // This avoids server roundtrips on filter change and provides instant UI feedback + // Fetch ALL PRs at once for instant client-side filtering + accurate counts. const { data: allMinerPRs, isLoading } = useAllPrs(); const allPRs = useMemo(() => { @@ -46,78 +67,95 @@ const RepositoryPRsTable: React.FC = ({ const counts = useMemo(() => { if (!allPRs) return { all: 0, open: 0, merged: 0, closed: 0 }; - return { - all: allPRs.length, - open: allPRs.filter( - (pr) => pr.prState === 'OPEN' || (!pr.prState && !pr.mergedAt), - ).length, - merged: allPRs.filter((pr) => pr.prState === 'MERGED' || !!pr.mergedAt) - .length, - closed: allPRs.filter((pr) => pr.prState === 'CLOSED' && !pr.mergedAt) - .length, - }; + return getPrStatusCounts(allPRs); }, [allPRs]); - const filteredPRs = useMemo(() => { - if (!allPRs) return []; - if (filter === 'all') return allPRs; - if (filter === 'merged') - return allPRs.filter((pr) => pr.prState === 'MERGED' || !!pr.mergedAt); - if (filter === 'open') - return allPRs.filter( - (pr) => pr.prState === 'OPEN' || (!pr.prState && !pr.mergedAt), + const filteredPRs = useMemo( + () => filterPrs(allPRs ?? [], { statusFilter: filter }), + [allPRs, filter], + ); + + const sortedPRs = useMemo(() => { + const dir = sortOrder === 'asc' ? 1 : -1; + const cmpStr = (a = '', b = '') => a.localeCompare(b) * dir; + const cmpNum = (a = 0, b = 0) => (a - b) * dir; + const stateRank = (pr: (typeof filteredPRs)[number]) => { + const s = pr.prState?.toUpperCase() || (pr.mergedAt ? 'MERGED' : 'OPEN'); + return ( + ({ OPEN: 0, MERGED: 1, CLOSED: 2 } as Record)[s] ?? 3 ); - if (filter === 'closed') - return allPRs.filter((pr) => pr.prState === 'CLOSED' && !pr.mergedAt); - return allPRs; - }, [allPRs, filter]); + }; - const sortedPRs = useMemo( - () => - [...filteredPRs].sort( - (a, b) => parseFloat(b.score || '0') - parseFloat(a.score || '0'), - ), - [filteredPRs], + return [...filteredPRs].sort((a, b) => { + switch (sortField) { + case 'pullRequestNumber': + return cmpNum(a.pullRequestNumber, b.pullRequestNumber); + case 'pullRequestTitle': + return cmpStr(a.pullRequestTitle, b.pullRequestTitle); + case 'author': + return cmpStr(a.author, b.author); + case 'commitCount': + return cmpNum(a.commitCount, b.commitCount); + case 'lines': + return cmpNum( + (a.additions ?? 0) + (a.deletions ?? 0), + (b.additions ?? 0) + (b.deletions ?? 0), + ); + case 'status': + return (stateRank(a) - stateRank(b)) * dir; + case 'mergedAt': + return cmpNum( + a.mergedAt ? new Date(a.mergedAt).getTime() : 0, + b.mergedAt ? new Date(b.mergedAt).getTime() : 0, + ); + case 'score': + default: + return cmpNum(parseFloat(a.score || '0'), parseFloat(b.score || '0')); + } + }); + }, [filteredPRs, sortField, sortOrder]); + + const handleRowClick = useCallback( + (pr: CommitLog) => { + navigate( + `/miners/pr?repo=${encodeURIComponent(pr.repository)}&number=${pr.pullRequestNumber}`, + { state: { backLabel: `Back to ${repositoryFullName}` } }, + ); + }, + [navigate, repositoryFullName], ); - const FilterButton = ({ - label, - value, - count, - color, - }: { - label: string; - value: typeof filter; - count?: number; - color: string; - }) => ( - + const filterButtons = ( + + setFilter('all')} + count={counts.all} + color={theme.palette.status.neutral} + /> + setFilter('open')} + count={counts.open} + color={theme.palette.status.open} + /> + setFilter('merged')} + count={counts.merged} + color={theme.palette.status.merged} + /> + setFilter('closed')} + count={counts.closed} + color={theme.palette.status.closed} + /> + ); if (isLoading) { @@ -125,7 +163,7 @@ const RepositoryPRsTable: React.FC = ({ = ({ elevation={0} > - + Pull Requests - - - - - - + {filterButtons} ); } + const columns: DataTableColumn[] = [ + { + key: 'pullRequestNumber', + header: 'PR #', + sortKey: 'pullRequestNumber', + renderCell: (pr) => ( + // Native to GitHub — `onRowClick` (no row-as-anchor) keeps this valid HTML. + e.stopPropagation()} + > + #{pr.pullRequestNumber} + + ), + }, + { + key: 'pullRequestTitle', + header: 'Title', + sortKey: 'pullRequestTitle', + renderCell: (pr) => ( + + {pr.pullRequestTitle} + + ), + }, + { + key: 'author', + header: 'Author', + sortKey: 'author', + renderCell: (pr) => ( + + + {pr.author} + + ), + }, + { + key: 'commitCount', + header: 'Commits', + align: 'right', + sortKey: 'commitCount', + renderCell: (pr) => pr.commitCount, + }, + { + key: 'lines', + header: '+/-', + align: 'right', + sortKey: 'lines', + renderCell: (pr) => ( + <> + + +{pr.additions} + + + -{pr.deletions} + + + ), + }, + { + key: 'score', + header: 'Score', + align: 'right', + sortKey: 'score', + renderCell: (pr) => ( + + {parseFloat(pr.score || '0').toFixed(4)} + + ), + }, + { + key: 'status', + header: 'Status', + sortKey: 'status', + renderCell: (pr) => { + const state = + pr.prState?.toUpperCase() || (pr.mergedAt ? 'MERGED' : 'OPEN'); + let color = theme.palette.status.neutral; + if (state === 'MERGED') color = theme.palette.status.merged; + else if (state === 'OPEN') color = theme.palette.status.open; + else if (state === 'CLOSED') color = theme.palette.status.closed; + return ( + + ); + }, + }, + { + key: 'mergedAt', + header: 'Merged', + align: 'right', + sortKey: 'mergedAt', + renderCell: (pr) => + pr.mergedAt ? new Date(pr.mergedAt).toLocaleDateString() : '-', + }, + ]; + + const headerToolbar = ( + + + Pull Requests ({sortedPRs.length}) + + {filterButtons} + + ); + return ( - + columns={columns} + rows={sortedPRs} + getRowKey={(pr) => `${pr.repository}-${pr.pullRequestNumber}`} + stickyHeader + size="medium" + header={headerToolbar} + emptyState={ + + + No pull requests found + + + } + onRowClick={handleRowClick} + sort={{ + field: sortField, + order: sortOrder, + onChange: handleSort, }} - > - - Pull Requests ({sortedPRs.length}) - - - - - - - - - - - {sortedPRs.length === 0 ? ( - - - No pull requests found - - - ) : ( - - - - - PR # - Title - Author - - Commits - - - +/- - - - Score - - Status - - Merged - - - - - {sortedPRs.map((pr, index) => ( - { - navigate( - `/miners/pr?repo=${encodeURIComponent(pr.repository)}&number=${pr.pullRequestNumber}`, - { state: { backLabel: `Back to ${repositoryFullName}` } }, - ); - }} - sx={{ - cursor: 'pointer', - '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.05)', - }, - transition: 'background-color 0.2s', - }} - > - - e.stopPropagation()} - > - #{pr.pullRequestNumber} - - - - - {pr.pullRequestTitle} - - - - - - - {pr.author} - - - - {pr.commitCount} - - - - +{pr.additions} - - - -{pr.deletions} - - - - - {parseFloat(pr.score || '0').toFixed(4)} - - - - {(() => { - const state = - pr.prState?.toUpperCase() || - (pr.mergedAt ? 'MERGED' : 'OPEN'); - let color = theme.palette.status.neutral; - const label = state; - - if (state === 'MERGED') { - color = theme.palette.status.merged; - } else if (state === 'OPEN') { - color = theme.palette.status.open; - } else if (state === 'CLOSED') { - color = theme.palette.status.closed; - } - - return ( - - ); - })()} - - - {pr.mergedAt - ? new Date(pr.mergedAt).toLocaleDateString() - : '-'} - - - ))} - -
-
- )} + />
); }; -const headerCellStyle = { - backgroundColor: 'rgba(18, 18, 20, 0.95)', - backdropFilter: 'blur(8px)', - color: 'rgba(255, 255, 255, 0.7)', - fontFamily: '"JetBrains Mono", monospace', - fontWeight: 500, - fontSize: '0.75rem', - borderBottom: '1px solid rgba(255, 255, 255, 0.1)', - textTransform: 'uppercase' as const, - letterSpacing: '0.5px', -}; - -const bodyCellStyle = { - color: '#ffffff', - fontFamily: '"JetBrains Mono", monospace', - borderBottom: '1px solid rgba(255, 255, 255, 0.1)', - fontSize: '0.85rem', -}; - export default RepositoryPRsTable; diff --git a/src/components/repositories/RepositoryScoreCard.tsx b/src/components/repositories/RepositoryScoreCard.tsx deleted file mode 100644 index 87e0eaef..00000000 --- a/src/components/repositories/RepositoryScoreCard.tsx +++ /dev/null @@ -1,375 +0,0 @@ -import React, { useMemo } from 'react'; -import { - Card, - Typography, - Box, - Grid, - CircularProgress, - Avatar, - alpha, -} from '@mui/material'; -import { useAllPrs } from '../../api'; -import { RANK_COLORS, STATUS_COLORS } from '../../theme'; - -interface RepositoryScoreCardProps { - repositoryFullName: string; -} - -const RepositoryScoreCard: React.FC = ({ - repositoryFullName, -}) => { - const { data: allPRs, isLoading } = useAllPrs(); - - const repoStats = useMemo(() => { - if (!allPRs) return null; - - const allRepoPRs = allPRs.filter( - (pr) => pr.repository.toLowerCase() === repositoryFullName.toLowerCase(), - ); - - if (allRepoPRs.length === 0) return null; - - const totalScore = allRepoPRs.reduce( - (sum, pr) => sum + parseFloat(pr.score || '0'), - 0, - ); - const totalLines = allRepoPRs.reduce( - (sum, pr) => sum + (pr.additions + pr.deletions), - 0, - ); - const totalCommits = allRepoPRs.reduce( - (sum, pr) => sum + pr.commitCount, - 0, - ); - - const uniqueContributors = new Set( - allRepoPRs.map((pr) => pr.githubId).filter((id): id is string => !!id), - ).size; - - // Calculate stats for all repositories to determine rankings - const repoStats = new Map< - string, - { - totalScore: number; - totalPRs: number; - totalLines: number; - totalCommits: number; - uniqueContributors: number; - } - >(); - - allPRs.forEach((pr) => { - const existing = repoStats.get(pr.repository) || { - totalScore: 0, - totalPRs: 0, - totalLines: 0, - totalCommits: 0, - uniqueContributors: 0, - }; - existing.totalScore += parseFloat(pr.score || '0'); - existing.totalPRs += 1; - existing.totalLines += pr.additions + pr.deletions; - existing.totalCommits += pr.commitCount; - repoStats.set(pr.repository, existing); - }); - - // Count unique contributors per repo - const repoContributors = new Map>(); - allPRs.forEach((pr) => { - if (!pr.githubId) return; // Skip PRs without githubId - if (!repoContributors.has(pr.repository)) { - repoContributors.set(pr.repository, new Set()); - } - repoContributors.get(pr.repository)!.add(pr.githubId); - }); - repoContributors.forEach((contribs, repo) => { - const stats = repoStats.get(repo); - if (stats) stats.uniqueContributors = contribs.size; - }); - - // Calculate rankings - const allRepos = Array.from(repoStats.entries()); - - const scoreRank = - allRepos - .sort((a, b) => b[1].totalScore - a[1].totalScore) - .findIndex( - ([repo]) => repo.toLowerCase() === repositoryFullName.toLowerCase(), - ) + 1; - - const prsRank = - allRepos - .sort((a, b) => b[1].totalPRs - a[1].totalPRs) - .findIndex( - ([repo]) => repo.toLowerCase() === repositoryFullName.toLowerCase(), - ) + 1; - - const contributorsRank = - allRepos - .sort((a, b) => b[1].uniqueContributors - a[1].uniqueContributors) - .findIndex( - ([repo]) => repo.toLowerCase() === repositoryFullName.toLowerCase(), - ) + 1; - - const linesRank = - allRepos - .sort((a, b) => b[1].totalLines - a[1].totalLines) - .findIndex( - ([repo]) => repo.toLowerCase() === repositoryFullName.toLowerCase(), - ) + 1; - - const commitsRank = - allRepos - .sort((a, b) => b[1].totalCommits - a[1].totalCommits) - .findIndex( - ([repo]) => repo.toLowerCase() === repositoryFullName.toLowerCase(), - ) + 1; - - return { - totalScore, - totalPRs: allRepoPRs.length, - totalLines, - totalCommits, - uniqueContributors, - rankings: { - score: scoreRank || null, - prs: prsRank || null, - contributors: contributorsRank || null, - lines: linesRank || null, - commits: commitsRank || null, - }, - }; - }, [allPRs, repositoryFullName]); - - if (isLoading) { - return ( - - - - ); - } - - if (!repoStats) { - return ( - - - No data found for repository: {repositoryFullName} - - - ); - } - - const [owner, repoName] = repositoryFullName.split('/'); - - const statItems = [ - { - label: 'Total Score', - value: repoStats.totalScore.toFixed(4), - rank: repoStats.rankings.score, - }, - { - label: 'Total PRs', - value: repoStats.totalPRs, - rank: repoStats.rankings.prs, - }, - { - label: 'Contributors', - value: repoStats.uniqueContributors, - rank: repoStats.rankings.contributors, - }, - { - label: 'Total Lines', - value: repoStats.totalLines.toLocaleString(), - rank: repoStats.rankings.lines, - }, - { - label: 'Total Commits', - value: repoStats.totalCommits, - rank: repoStats.rankings.commits, - }, - ]; - - return ( - - - - - - {repoName} - - - {owner} - - - - - - {statItems.map((item, index) => ( - - - - - {item.label} - - {item.rank && ( - - - {item.rank} - - - )} - - - {item.value} - - - - ))} - - - ); -}; - -export default RepositoryScoreCard; diff --git a/src/components/repositories/RepositoryStats.tsx b/src/components/repositories/RepositoryStats.tsx index 15e92e08..2b4cddb6 100644 --- a/src/components/repositories/RepositoryStats.tsx +++ b/src/components/repositories/RepositoryStats.tsx @@ -1,5 +1,12 @@ import React, { useMemo } from 'react'; -import { Box, Typography, Skeleton, Divider, Chip } from '@mui/material'; +import { + Box, + Typography, + Skeleton, + Divider, + Chip, + useTheme, +} from '@mui/material'; import { useReposAndWeights, useAllPrs, @@ -8,6 +15,8 @@ import { useRepositoryConfig, } from '../../api'; import { RANK_COLORS, STATUS_COLORS } from '../../theme'; +import { formatTokenAmount } from '../../utils/format'; +import { isMergedPr } from '../../utils/prStatus'; interface RepositoryStatsProps { repositoryFullName: string; @@ -16,6 +25,7 @@ interface RepositoryStatsProps { const RepositoryStats: React.FC = ({ repositoryFullName, }) => { + const theme = useTheme(); const { data: repos, isLoading: isLoadingRepos } = useReposAndWeights(); const { data: allPRs, isLoading: isLoadingPRs } = useAllPrs(); const { data: issues, isLoading: isLoadingIssues } = @@ -37,7 +47,7 @@ const RepositoryStats: React.FC = ({ const repoPRs = allPRs.filter( (pr) => pr.repository.toLowerCase() === repositoryFullName.toLowerCase() && - pr.prState === 'MERGED', + isMergedPr(pr), ); const totalScore = repoPRs.reduce( (acc, pr) => acc + parseFloat(pr.score || '0'), @@ -64,14 +74,19 @@ const RepositoryStats: React.FC = ({ Repository Stats ); @@ -85,7 +100,7 @@ const RepositoryStats: React.FC = ({ Repository Stats @@ -108,8 +123,7 @@ const RepositoryStats: React.FC = ({ @@ -117,7 +131,7 @@ const RepositoryStats: React.FC = ({ - + {/* Total Score */} = ({ @@ -164,8 +177,7 @@ const RepositoryStats: React.FC = ({ @@ -190,8 +202,7 @@ const RepositoryStats: React.FC = ({ @@ -202,7 +213,7 @@ const RepositoryStats: React.FC = ({ {/* Bounties */} {bountySummary && bountySummary.totalBounties > 0 && ( <> - + {/* Total Bounties */} = ({ variant="body2" sx={{ color: RANK_COLORS.first, - fontFamily: '"JetBrains Mono", monospace', fontSize: '13px', fontWeight: 600, }} @@ -249,18 +259,11 @@ const RepositoryStats: React.FC = ({ - {parseFloat(bountySummary.totalAvailable).toLocaleString( - undefined, - { - maximumFractionDigits: 2, - }, - )}{' '} - α + {formatTokenAmount(bountySummary.totalAvailable, 2)} α )} @@ -284,18 +287,11 @@ const RepositoryStats: React.FC = ({ variant="body2" sx={{ color: STATUS_COLORS.merged, - fontFamily: '"JetBrains Mono", monospace', fontSize: '13px', fontWeight: 500, }} > - {parseFloat(bountySummary.totalPaidOut).toLocaleString( - undefined, - { - maximumFractionDigits: 2, - }, - )}{' '} - α + {formatTokenAmount(bountySummary.totalPaidOut, 2)} α )} @@ -306,7 +302,7 @@ const RepositoryStats: React.FC = ({ {repoConfig?.additionalAcceptableBranches && repoConfig.additionalAcceptableBranches.length > 0 && ( <> - + = ({ label={branch} size="small" sx={{ - fontFamily: '"JetBrains Mono", monospace', fontSize: '12px', height: '24px', - bgcolor: 'rgba(255,255,255,0.06)', - color: '#fff', - border: '1px solid rgba(255,255,255,0.1)', + bgcolor: 'surface.light', + color: 'text.primary', + border: `1px solid ${theme.palette.border.light}`, }} /> ))} diff --git a/src/components/repositories/RepositoryWeightsTable.tsx b/src/components/repositories/RepositoryWeightsTable.tsx deleted file mode 100644 index fab10266..00000000 --- a/src/components/repositories/RepositoryWeightsTable.tsx +++ /dev/null @@ -1,830 +0,0 @@ -import React, { useState, useMemo, useRef, useEffect } from 'react'; -import { - Box, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - TableSortLabel, - TablePagination, - TextField, - Typography, - Paper, - InputAdornment, - Stack, - useMediaQuery, - useTheme, - CircularProgress, - Tooltip, - Select, - MenuItem, - FormControl, - Avatar, - IconButton, - Collapse, -} from '@mui/material'; -import { Search } from '@mui/icons-material'; -import BarChartIcon from '@mui/icons-material/BarChart'; -import TableChartIcon from '@mui/icons-material/TableChart'; -import ReactECharts from 'echarts-for-react'; -import { useReposAndWeights } from '../../api'; -import dayjs from 'dayjs'; - -type SortField = 'owner' | 'name' | 'weight'; -type SortOrder = 'asc' | 'desc'; - -const baseGithubUrl = 'https://github.com/'; - -const AnimatedWeightBar = ({ - weight, - maxWeight, -}: { - weight: number; - maxWeight: number; -}) => { - const [width, setWidth] = useState(0); - - useEffect(() => { - const timer = setTimeout(() => { - setWidth((weight / maxWeight) * 100); - }, 50); - return () => clearTimeout(timer); - }, [weight, maxWeight]); - - return ( - - - - ); -}; - -const RepositoryWeightsTable: React.FC = () => { - const { data, isLoading } = useReposAndWeights(); - const [searchQuery, setSearchQuery] = useState(''); - const [sortField, setSortField] = useState('weight'); - const [sortOrder, setSortOrder] = useState('desc'); - const [showChart, setShowChart] = useState(false); - const [page, setPage] = useState(0); - const [rowsPerPage, setRowsPerPage] = useState(10); - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - const containerRef = useRef(null); - - const handleSort = (field: SortField) => { - if (sortField === field) { - setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); - } else { - setSortField(field); - setSortOrder(field === 'weight' ? 'desc' : 'asc'); - } - setPage(0); - }; - - const handleChangePage = (_event: unknown, newPage: number) => { - setPage(newPage); - }; - - const handleChangeRowsPerPage = ( - event: React.ChangeEvent, - ) => { - setRowsPerPage(parseInt(event.target.value, 10)); - setPage(0); - }; - - const handleSearchChange = (event: React.ChangeEvent) => { - setSearchQuery(event.target.value); - setPage(0); - }; - - const filteredAndSortedRepos = useMemo(() => { - if (!data) return []; - - const reposWithParts = data.map((repo) => { - const [owner, name] = repo.fullName.split('/'); - return { ...repo, owner, name }; - }); - - const filtered = reposWithParts.filter((repo) => { - const searchLower = searchQuery.toLowerCase(); - return ( - repo.owner.toLowerCase().includes(searchLower) || - repo.name.toLowerCase().includes(searchLower) - ); - }); - - filtered.sort((a, b) => { - let aValue: string | number; - let bValue: string | number; - - if (sortField === 'owner') { - aValue = a.owner; - bValue = b.owner; - } else if (sortField === 'name') { - aValue = a.name; - bValue = b.name; - } else { - aValue = a.weight; - bValue = b.weight; - } - - // For weight field, always parse as numbers - if (sortField === 'weight') { - const aNum = parseFloat(aValue as string); - const bNum = parseFloat(bValue as string); - return sortOrder === 'asc' ? aNum - bNum : bNum - aNum; - } - - // For string fields (owner, name), use localeCompare - if (typeof aValue === 'string' && typeof bValue === 'string') { - return sortOrder === 'asc' - ? aValue.localeCompare(bValue) - : bValue.localeCompare(aValue); - } - - return 0; - }); - - return filtered; - }, [data, searchQuery, sortField, sortOrder]); - - const maxWeight = useMemo(() => { - if (filteredAndSortedRepos.length === 0) return 1; - const weights = filteredAndSortedRepos - .map((r) => parseFloat(r.weight as string)) - .filter((w) => !isNaN(w)); - return weights.length > 0 ? Math.max(...weights) : 1; - }, [filteredAndSortedRepos]); - - const getChartOption = () => { - const chartData = filteredAndSortedRepos; - - const weights = filteredAndSortedRepos - .map((r) => parseFloat(r.weight as string)) - .filter((w) => !isNaN(w)); - - const minWeight = weights.length > 0 ? Math.min(...weights) : 0; - - const textColor = 'rgba(255, 255, 255, 0.85)'; - const gridColor = 'rgba(255, 255, 255, 0.08)'; - - const xAxisData = chartData.map((item) => item.name); - - const seriesData = chartData.map((item) => ({ - value: parseFloat(item.weight as string) || 0, - name: item.name, - fullName: item.fullName, - owner: item.owner, - itemStyle: { - color: { - type: 'linear', - x: 0, - y: 0, - x2: 0, - y2: 1, - colorStops: [ - { offset: 0, color: 'rgba(139, 148, 158, 0.8)' }, - { offset: 1, color: 'rgba(100, 108, 118, 0.4)' }, - ], - }, - borderRadius: [4, 4, 0, 0], - }, - })); - - return { - backgroundColor: 'transparent', - title: { - text: 'Repository Weights Distribution', - subtext: 'Weight distribution across repositories', - left: 'center', - top: 20, - textStyle: { - color: '#ffffff', - fontFamily: 'JetBrains Mono', - fontSize: 18, - fontWeight: 600, - }, - subtextStyle: { - color: 'rgba(255, 255, 255, 0.5)', - fontFamily: 'JetBrains Mono', - fontSize: 12, - }, - }, - tooltip: { - trigger: 'axis', - axisPointer: { type: 'shadow' }, - backgroundColor: 'rgba(15, 15, 18, 0.95)', - borderColor: 'rgba(255, 255, 255, 0.15)', - borderWidth: 1, - textStyle: { color: '#fff', fontFamily: 'JetBrains Mono' }, - formatter: (params: any) => { - const data = params[0]?.data; - if (!data) return ''; - return ` -
-
- -
${data.fullName}
-
-
Weight: ${data.value}
-
- `; - }, - }, - grid: { - left: '3%', - right: '3%', - bottom: '10%', - top: '20%', - containLabel: true, - }, - xAxis: { - type: 'category', - data: xAxisData, - axisLabel: { - show: chartData.length < 50, - color: textColor, - fontFamily: 'JetBrains Mono', - rotate: 45, - interval: 0, - formatter: (val: string) => - val.length > 15 ? `${val.slice(0, 12)}...` : val, - }, - axisLine: { lineStyle: { color: gridColor } }, - }, - yAxis: { - type: 'value', - min: minWeight, - max: maxWeight, - name: 'Weight', - nameTextStyle: { color: textColor, fontFamily: 'JetBrains Mono' }, - axisLabel: { color: textColor, fontFamily: 'JetBrains Mono' }, - splitLine: { lineStyle: { color: gridColor, type: 'dashed' } }, - }, - series: [ - { - data: seriesData, - type: 'bar', - barWidth: chartData.length > 50 ? '80%' : '60%', - emphasis: { focus: 'series' }, - animationDuration: chartData.length > 100 ? 1000 : 1500, - animationEasing: 'cubicOut', - animationDelay: (idx: number) => - idx * (chartData.length > 100 ? 1 : 10), - }, - ], - }; - }; - - const paginatedRepos = useMemo(() => { - const startIndex = page * rowsPerPage; - const endIndex = startIndex + rowsPerPage; - return filteredAndSortedRepos.slice(startIndex, endIndex); - }, [filteredAndSortedRepos, page, rowsPerPage]); - - // Scroll to top when rows per page changes - useEffect(() => { - if (containerRef.current) { - containerRef.current.scrollIntoView({ - behavior: 'smooth', - block: 'start', - }); - } - }, [rowsPerPage]); - - return ( - - - - - Contribute to any of these projects to gain score and earn emissions - - - - - - - setShowChart(!showChart)} - size="small" - sx={{ - color: showChart ? '#ffffff' : 'rgba(255, 255, 255, 0.5)', - border: '1px solid rgba(255, 255, 255, 0.1)', - borderRadius: 2, - padding: '6px', - '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.05)', - borderColor: 'rgba(255, 255, 255, 0.2)', - }, - }} - > - {showChart ? ( - - ) : ( - - )} - - - - - - - Rows: - - - - - - - - ), - }} - sx={{ - width: '200px', - '& .MuiOutlinedInput-root': { - color: '#ffffff', - fontFamily: '"JetBrains Mono", monospace', - backgroundColor: 'rgba(0, 0, 0, 0.4)', - fontSize: '0.8rem', - height: '36px', - borderRadius: 2, - '& fieldset': { borderColor: 'rgba(255, 255, 255, 0.1)' }, - '&:hover fieldset': { - borderColor: 'rgba(255, 255, 255, 0.2)', - }, - '&.Mui-focused fieldset': { borderColor: 'primary.main' }, - }, - }} - /> - - - - - - - {showChart && filteredAndSortedRepos.length > 0 && ( - - )} - - - - {isLoading ? ( - - - - ) : ( - - - - - {!isMobile && ( - - handleSort('owner')} - sx={{ - '&:hover': { - color: 'secondary.main', - }, - '&.Mui-active': { - color: 'secondary.main', - }, - }} - > - Owner - - - )} - - handleSort('name')} - sx={{ - '&:hover': { - color: 'secondary.main', - }, - '&.Mui-active': { - color: 'secondary.main', - }, - }} - > - Repository - - - - handleSort('weight')} - sx={{ - '&:hover': { - color: 'secondary.main', - }, - '&.Mui-active': { - color: 'secondary.main', - }, - }} - > - Weight - - - - - - {paginatedRepos.map((repo) => { - const isInactive = - repo.inactiveAt !== null && repo.inactiveAt !== undefined; - const inactiveDate = isInactive - ? dayjs(repo.inactiveAt).format('DD/MM/YY hh:mm a') - : null; - - return ( - - - {!isMobile && ( - - - - - - - {repo.owner} - - - - )} - - - {isMobile && ( - - - - )} - - - {isMobile ? repo.fullName : repo.name} - - {!isMobile && ( - - {baseGithubUrl} - {repo.fullName} - - )} - - - - - - - {repo.weight} - - {!isMobile && ( - - )} - - - - - ); - })} - -
-
- )} - - - - {filteredAndSortedRepos.length === 0 && !isLoading && ( - - No repositories found! - - )} -
- ); -}; - -export default RepositoryWeightsTable; diff --git a/src/components/repositories/index.ts b/src/components/repositories/index.ts index a3c79d90..66eb6d0e 100644 --- a/src/components/repositories/index.ts +++ b/src/components/repositories/index.ts @@ -1,7 +1,4 @@ -export { default as RepositoryWeightsTable } from './RepositoryWeightsTable'; export { default as LanguageWeightsTable } from './LanguageWeightsTable'; -export { default as RepositoryDetails } from './RepositoryDetails'; -export { default as RepositoryScoreCard } from './RepositoryScoreCard'; export { default as RepositoryContributorsTable } from './RepositoryContributorsTable'; export { default as RepositoryStats } from './RepositoryStats'; export { default as ContributingViewer } from './ContributingViewer'; diff --git a/src/hooks/useDataTableParams.ts b/src/hooks/useDataTableParams.ts new file mode 100644 index 00000000..8961b22c --- /dev/null +++ b/src/hooks/useDataTableParams.ts @@ -0,0 +1,313 @@ +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { type SortOrder } from '../utils/ExplorerUtils'; + +type ParamKeys = { + sort: string; + order: string; + page: string; + rowsPerPage: string; +}; + +const DEFAULT_PARAM_KEYS: ParamKeys = { + sort: 'sort', + order: 'dir', + page: 'page', + rowsPerPage: 'rows', +}; + +/** + * Configuration for an additional URL-backed filter (search, enum, etc.) + * that should be co-located with the table's sort/pagination state. + */ +export type FilterConfig = { + parse: (raw: string | null) => T; + /** Return `null` to delete the param (used for "default" values). */ + serialize: (value: T) => string | null; + /** URL parameter name. Defaults to the filter's key in the `filters` map. */ + paramKey?: string; + /** + * When true (default), changing this filter deletes the `page` slot — + * appropriate for filters that change the result set. Set to `false` + * for preferences like view mode that shouldn't reset pagination. + */ + resetPageOnChange?: boolean; +}; + +export type UseDataTableParamsConfig< + SortKey extends string, + Filters extends Record = Record, +> = { + sortKeys: readonly SortKey[]; + defaultSortKey: SortKey; + defaultSortOrder?: SortOrder; + // Per-field override for the order applied the first time a column becomes + // active — string columns often feel natural ascending, numeric descending. + defaultOrderOverrides?: Partial>; + defaultRowsPerPage?: number; + rowsPerPageOptions?: readonly number[]; + // Override URL parameter names. Useful when multiple tables coexist on the + // same page (e.g. prefix with the table name). + paramKeys?: Partial; + /** + * Additional URL-backed filters beyond sort/pagination. Each entry + * provides `parse` / `serialize` so the hook stays type-safe. + */ + filters?: { [K in keyof Filters]: FilterConfig }; +}; + +export type UseDataTableParamsResult< + SortKey extends string, + Filters extends Record = Record, +> = { + sortField: SortKey; + sortOrder: SortOrder; + page: number; + rowsPerPage: number; + setSort: (field: SortKey) => void; + setPage: (page: number) => void; + setRowsPerPage: (rowsPerPage: number) => void; + filters: Filters; + setFilter: (key: K, value: Filters[K]) => void; +}; + +const parseSortField = ( + value: string | null, + sortKeys: readonly K[], + defaultKey: K, +): K => { + if (value && (sortKeys as readonly string[]).includes(value)) { + return value as K; + } + return defaultKey; +}; + +const parseSortOrder = ( + value: string | null, + fallback: SortOrder, +): SortOrder => { + if (value === 'asc') return 'asc'; + if (value === 'desc') return 'desc'; + return fallback; +}; + +const parsePage = (value: string | null): number => { + if (!value) return 0; + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; +}; + +const parseRowsPerPage = ( + value: string | null, + defaultRowsPerPage: number, + options?: readonly number[], +): number => { + if (!value) return defaultRowsPerPage; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return defaultRowsPerPage; + if (options && !options.includes(parsed)) return defaultRowsPerPage; + return parsed; +}; + +const computeNextSort = ( + currentField: K, + currentOrder: SortOrder, + nextField: K, + orderFor: (field: K) => SortOrder, +): { field: K; order: SortOrder } => { + if (currentField === nextField) { + return { + field: nextField, + order: currentOrder === 'asc' ? 'desc' : 'asc', + }; + } + return { field: nextField, order: orderFor(nextField) }; +}; + +// ---------- Hook ---------- + +export const useDataTableParams = < + SortKey extends string, + Filters extends Record = Record, +>({ + sortKeys, + defaultSortKey, + defaultSortOrder = 'desc', + defaultOrderOverrides, + defaultRowsPerPage = 25, + rowsPerPageOptions, + paramKeys: paramKeysOverride, + filters: filtersConfig, +}: UseDataTableParamsConfig): UseDataTableParamsResult< + SortKey, + Filters +> => { + const [searchParams, setSearchParams] = useSearchParams(); + + const paramKeys = useMemo( + () => ({ ...DEFAULT_PARAM_KEYS, ...paramKeysOverride }), + [paramKeysOverride], + ); + + const orderFor = useCallback( + (field: SortKey): SortOrder => + defaultOrderOverrides?.[field] ?? defaultSortOrder, + [defaultOrderOverrides, defaultSortOrder], + ); + + const sortField = useMemo( + () => + parseSortField( + searchParams.get(paramKeys.sort), + sortKeys, + defaultSortKey, + ), + [searchParams, paramKeys.sort, sortKeys, defaultSortKey], + ); + + const sortOrder = useMemo( + () => + parseSortOrder(searchParams.get(paramKeys.order), orderFor(sortField)), + [searchParams, paramKeys.order, orderFor, sortField], + ); + + const page = useMemo( + () => parsePage(searchParams.get(paramKeys.page)), + [searchParams, paramKeys.page], + ); + + const rowsPerPage = useMemo( + () => + parseRowsPerPage( + searchParams.get(paramKeys.rowsPerPage), + defaultRowsPerPage, + rowsPerPageOptions, + ), + [ + searchParams, + paramKeys.rowsPerPage, + defaultRowsPerPage, + rowsPerPageOptions, + ], + ); + + // Re-compute on every render. Parsers may be inline functions (non-stable + // refs), so memoising on filtersConfig wouldn't help. The work is cheap — + // URLSearchParams reads plus caller-supplied parse calls. + const filters = useMemo(() => { + const result: Record = {}; + if (filtersConfig) { + for (const key of Object.keys(filtersConfig) as (keyof Filters)[]) { + const config = filtersConfig[key]; + const paramKey = config.paramKey ?? (key as string); + result[key as string] = config.parse(searchParams.get(paramKey)); + } + } + return result as Filters; + }, [searchParams, filtersConfig]); + + const setSort = useCallback( + (nextField: SortKey) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + const currentField = parseSortField( + prev.get(paramKeys.sort), + sortKeys, + defaultSortKey, + ); + const currentOrder = parseSortOrder( + prev.get(paramKeys.order), + orderFor(currentField), + ); + const nextSort = computeNextSort( + currentField, + currentOrder, + nextField, + orderFor, + ); + + if (nextSort.field === defaultSortKey) next.delete(paramKeys.sort); + else next.set(paramKeys.sort, nextSort.field); + + if (nextSort.order === orderFor(nextSort.field)) + next.delete(paramKeys.order); + else next.set(paramKeys.order, nextSort.order); + + // Sort change resets the current page slot. + next.delete(paramKeys.page); + + return next; + }, + { replace: true }, + ); + }, + [setSearchParams, paramKeys, sortKeys, defaultSortKey, orderFor], + ); + + const setPage = useCallback( + (nextPage: number) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (nextPage <= 0) next.delete(paramKeys.page); + else next.set(paramKeys.page, String(nextPage)); + return next; + }, + { replace: true }, + ); + }, + [setSearchParams, paramKeys.page], + ); + + const setRowsPerPage = useCallback( + (nextRowsPerPage: number) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (nextRowsPerPage === defaultRowsPerPage) + next.delete(paramKeys.rowsPerPage); + else next.set(paramKeys.rowsPerPage, String(nextRowsPerPage)); + // Row size change invalidates the current page index. + next.delete(paramKeys.page); + return next; + }, + { replace: true }, + ); + }, + [setSearchParams, paramKeys, defaultRowsPerPage], + ); + + const setFilter = useCallback( + (key: K, value: Filters[K]) => { + if (!filtersConfig) return; + const config = filtersConfig[key]; + const filterParamKey = config.paramKey ?? (key as string); + const resetPage = config.resetPageOnChange ?? true; + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + const serialized = config.serialize(value); + if (serialized === null) next.delete(filterParamKey); + else next.set(filterParamKey, serialized); + if (resetPage) next.delete(paramKeys.page); + return next; + }, + { replace: true }, + ); + }, + [filtersConfig, setSearchParams, paramKeys.page], + ); + + return { + sortField, + sortOrder, + page, + rowsPerPage, + setSort, + setPage, + setRowsPerPage, + filters, + setFilter, + }; +}; diff --git a/src/hooks/useOnNavigate.ts b/src/hooks/useOnNavigate.ts index 2503e4cf..90ff0a11 100644 --- a/src/hooks/useOnNavigate.ts +++ b/src/hooks/useOnNavigate.ts @@ -1,11 +1,14 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { useLocation } from 'react-router-dom'; export const useOnNavigate = (callback: () => void) => { const { pathname } = useLocation(); + const callbackRef = useRef(callback); + callbackRef.current = callback; + useEffect(() => { - callback(); - }, [callback, pathname]); + callbackRef.current(); + }, [pathname]); }; export default useOnNavigate; diff --git a/src/hooks/usePrices.ts b/src/hooks/usePrices.ts new file mode 100644 index 00000000..d72afc70 --- /dev/null +++ b/src/hooks/usePrices.ts @@ -0,0 +1,12 @@ +import { useStats } from '../api'; + +/** + * Shared hook that extracts live Tao and Alpha prices from the dashboard stats. + * Replaces the 3-line boilerplate that was duplicated in every issue component. + */ +export const usePrices = () => { + const { data: dashStats } = useStats(); + const taoPrice = dashStats?.prices?.tao?.data?.price ?? 0; + const alphaPrice = dashStats?.prices?.alpha?.data?.price ?? 0; + return { taoPrice, alphaPrice, hasPrices: taoPrice > 0 && alphaPrice > 0 }; +}; diff --git a/src/hooks/useWatchlist.ts b/src/hooks/useWatchlist.ts new file mode 100644 index 00000000..7c5232ea --- /dev/null +++ b/src/hooks/useWatchlist.ts @@ -0,0 +1,243 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +// TODO(2026-Q3): drop V1_KEY read path once the rollback window closes. +const V1_KEY = 'gittensor.watchlist.v1'; +const V2_KEY = 'gittensor.watchlist.v2'; + +export type WatchlistCategory = 'miners' | 'repos' | 'bounties' | 'prs'; + +const CATEGORIES: readonly WatchlistCategory[] = [ + 'miners', + 'repos', + 'bounties', + 'prs', +] as const; + +type WatchlistState = Record; + +const EMPTY_STATE: WatchlistState = { + miners: [], + repos: [], + bounties: [], + prs: [], +}; + +type Listener = () => void; +const listeners = new Set(); + +const toStringArray = (value: unknown): string[] => + Array.isArray(value) + ? value.filter((x): x is string => typeof x === 'string') + : []; + +// Read v2 if present, otherwise migrate v1 (legacy miner-only string[]) into +// the new shape. The v1 key is left intact so a downgrade can still recover. +const readFromStorage = (): WatchlistState => { + try { + const v2Raw = window.localStorage.getItem(V2_KEY); + if (v2Raw) { + const parsed = JSON.parse(v2Raw); + if (parsed && typeof parsed === 'object') { + return { + miners: toStringArray((parsed as Record).miners), + repos: toStringArray((parsed as Record).repos), + bounties: toStringArray((parsed as Record).bounties), + prs: toStringArray((parsed as Record).prs), + }; + } + return EMPTY_STATE; + } + const v1Raw = window.localStorage.getItem(V1_KEY); + if (v1Raw) { + return { ...EMPTY_STATE, miners: toStringArray(JSON.parse(v1Raw)) }; + } + return EMPTY_STATE; + } catch { + return EMPTY_STATE; + } +}; + +// Module-level snapshot — every useWatchlist() instance in the tab reads from +// the same reference, so writes from any caller propagate to all consumers. +let snapshot: WatchlistState = readFromStorage(); + +// Stable per-category id arrays so React's identity check skips re-renders +// for tabs whose category did not change between writes. +let categoryCache: WatchlistState = snapshot; + +const notify = () => { + listeners.forEach((l) => l()); +}; + +const arraysEqual = (a: string[], b: string[]) => + a.length === b.length && a.every((v, i) => v === b[i]); + +// Apply a new snapshot to the in-memory store. When `persist` is true the +// value is also written back to localStorage; when false (cross-tab echo) +// we skip the write because another tab has already persisted it. +const applySnapshot = (next: WatchlistState, persist: boolean) => { + const nextCache: WatchlistState = { ...categoryCache }; + let changed = false; + for (const cat of CATEGORIES) { + if (!arraysEqual(next[cat], categoryCache[cat])) { + nextCache[cat] = next[cat]; + changed = true; + } + } + if (!changed) return; + snapshot = next; + categoryCache = nextCache; + if (persist) { + try { + window.localStorage.setItem(V2_KEY, JSON.stringify(next)); + } catch { + // Storage unavailable (private mode, quota). In-memory state still works. + } + } + notify(); +}; + +const setSnapshot = (next: WatchlistState) => applySnapshot(next, true); + +const handleStorageEvent = (e: StorageEvent) => { + if (e.key !== V2_KEY && e.key !== V1_KEY) return; + applySnapshot(readFromStorage(), false); +}; + +const subscribe = (listener: Listener) => { + if (listeners.size === 0) { + window.addEventListener('storage', handleStorageEvent); + } + listeners.add(listener); + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + window.removeEventListener('storage', handleStorageEvent); + } + }; +}; + +const totalCountOf = (state: WatchlistState) => + state.miners.length + + state.repos.length + + state.bounties.length + + state.prs.length; + +interface UseWatchlist { + ids: string[]; + count: number; + isWatched: (id: string) => boolean; + add: (id: string) => void; + remove: (id: string) => void; + toggle: (id: string) => void; + clear: () => void; +} + +export const useWatchlist = ( + category: WatchlistCategory = 'miners', +): UseWatchlist => { + const getter = useCallback( + (): string[] => categoryCache[category], + [category], + ); + const ids = useSyncExternalStore(subscribe, getter, getter); + + const isWatched = useCallback( + (id: string) => snapshot[category].includes(id), + // Depending on `ids` ensures consumers re-render when the snapshot changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + [ids, category], + ); + + // Action callbacks read from the module-level `snapshot`, not the rendered + // `ids`. This is the key to rapid-click correctness: two fast toggles see + // each other's writes immediately instead of both reading a stale array. + const add = useCallback( + (id: string) => { + if (!id || snapshot[category].includes(id)) return; + setSnapshot({ ...snapshot, [category]: [...snapshot[category], id] }); + }, + [category], + ); + + const remove = useCallback( + (id: string) => { + if (!snapshot[category].includes(id)) return; + setSnapshot({ + ...snapshot, + [category]: snapshot[category].filter((x) => x !== id), + }); + }, + [category], + ); + + const toggle = useCallback( + (id: string) => { + if (!id) return; + const list = snapshot[category]; + setSnapshot({ + ...snapshot, + [category]: list.includes(id) + ? list.filter((x) => x !== id) + : [...list, id], + }); + }, + [category], + ); + + const clear = useCallback( + () => setSnapshot({ ...snapshot, [category]: [] }), + [category], + ); + + return { + ids, + count: ids.length, + isWatched, + add, + remove, + toggle, + clear, + }; +}; + +// Subscribes to changes in any category. Use this when a consumer cares +// about the aggregate (e.g. the sidebar badge) and would otherwise miss +// updates that happen outside its current category. +const getTotalCountSnapshot = () => totalCountOf(snapshot); + +export const useWatchlistTotalCount = (): number => + useSyncExternalStore(subscribe, getTotalCountSnapshot, getTotalCountSnapshot); + +// Stable per-category count map. Recomputed only when the snapshot changes, +// so consumers (e.g. tab badges) hold a single subscription and see the +// same reference across renders when nothing changed. +type CountsMap = Record; +let countsCache: CountsMap = { + miners: snapshot.miners.length, + repos: snapshot.repos.length, + bounties: snapshot.bounties.length, + prs: snapshot.prs.length, +}; +let countsCacheSource: WatchlistState = snapshot; + +const getCountsSnapshot = (): CountsMap => { + if (countsCacheSource !== snapshot) { + countsCache = { + miners: snapshot.miners.length, + repos: snapshot.repos.length, + bounties: snapshot.bounties.length, + prs: snapshot.prs.length, + }; + countsCacheSource = snapshot; + } + return countsCache; +}; + +export const useWatchlistCounts = (): CountsMap => + useSyncExternalStore(subscribe, getCountsSnapshot, getCountsSnapshot); + +// PRs are identified by a composite "owner/repo#number" key. Callers +// should always use this helper to avoid drift in key format. +export const serializePRKey = (repo: string, number: number): string => + `${repo}#${number}`; diff --git a/src/main.tsx b/src/main.tsx index bbf0417d..570abfc1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,7 @@ import { ThemeProvider } from '@mui/material/styles'; import { CssBaseline } from '@mui/material'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { HelmetProvider } from 'react-helmet-async'; +import ErrorBoundary from './components/ErrorBoundary'; import './index.css'; const queryClient = new QueryClient({ @@ -27,7 +28,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + + + diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx deleted file mode 100644 index 0aba26c0..00000000 --- a/src/pages/DashboardPage.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import React from 'react'; -import { useMediaQuery, Box, Grid } from '@mui/material'; -import { Page } from '../components/layout'; -import { - LeaderboardCharts, - RepositoriesTable, - KpiCard, - LiveCommitLog, - SEO, - GlobalActivity, -} from '../components'; -import theme from '../theme'; -import { useStats } from '../api'; - -const DashboardPage: React.FC = () => { - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); - const isTablet = useMediaQuery(theme.breakpoints.between('sm', 'md')); - const isLargeScreen = useMediaQuery(theme.breakpoints.up('xl')); - const showSidebarRight = useMediaQuery(theme.breakpoints.up('xl')); // Only show sidebar on right for extra large screens - - const { data: stats } = useStats(); - - // Dynamic sidebar width based on screen size - const sidebarWidth = - isMobile || isTablet ? '100%' : isLargeScreen ? '340px' : '300px'; - - return ( - - - - {/* Main Content Area */} - - {/* Top Row: Global Activity Viz */} - - - - - {/* Charts and Table Section */} - - {/* Leaderboard Charts */} - - - - - {/* Table */} - - - - - - {/* KPI Cards - Moved Below Charts */} - - - - - - - - - - - - - - - - - - {/* Right Sidebar - Live Commit Log */} - - - - - - ); -}; - -export default DashboardPage; diff --git a/src/pages/DiscoveriesPage.tsx b/src/pages/DiscoveriesPage.tsx index 3e78246b..f54dbf1c 100644 --- a/src/pages/DiscoveriesPage.tsx +++ b/src/pages/DiscoveriesPage.tsx @@ -1,24 +1,25 @@ import React, { useMemo } from 'react'; import { useMediaQuery, Box, Typography, alpha } from '@mui/material'; -import { useNavigate } from 'react-router-dom'; import { Page } from '../components/layout'; -import { TopMinersTable, LeaderboardSidebar, SEO } from '../components'; +import { + TopMinersTable, + LeaderboardSidebar, + SEO, + type MinerStats, +} from '../components'; import { useAllMiners } from '../api'; -import theme from '../theme'; +import theme, { scrollbarSx } from '../theme'; +import { parseNumber } from '../utils/ExplorerUtils'; -const DiscoveriesPage: React.FC = () => { - const navigate = useNavigate(); +const MINER_LINK_STATE = { backLabel: 'Back to Discoveries' } as const; +const getMinerHref = (miner: MinerStats) => + `/miners/details?githubId=${miner.githubId}&mode=issues`; +const DiscoveriesPage: React.FC = () => { const allMinerStatsQuery = useAllMiners(); const allMinersStats = allMinerStatsQuery?.data; const isLoadingMinerStats = allMinerStatsQuery?.isLoading; - const handleSelectMiner = (githubId: string) => { - navigate(`/discoveries/details?githubId=${githubId}`, { - state: { backLabel: 'Back to Discoveries' }, - }); - }; - // Process miner stats for TopMinersTable, using issue discovery fields const minerStats = useMemo(() => { if (!Array.isArray(allMinersStats)) return []; @@ -26,23 +27,29 @@ const DiscoveriesPage: React.FC = () => { id: String(stat.id), githubId: stat.githubId || '', author: stat.githubUsername || undefined, - totalScore: Number(stat.issueDiscoveryScore) || 0, - baseTotalScore: Number(stat.baseTotalScore) || 0, - totalPRs: - (Number(stat.totalSolvedIssues) || 0) + - (Number(stat.totalClosedIssues) || 0), - linesChanged: Number(stat.totalNodesScored) || 0, - linesAdded: Number(stat.totalAdditions) || 0, - linesDeleted: Number(stat.totalDeletions) || 0, + totalScore: parseNumber(stat.issueDiscoveryScore), + baseTotalScore: parseNumber(stat.baseTotalScore), + totalPRs: parseNumber(stat.totalPrs), + totalIssues: + parseNumber(stat.totalSolvedIssues) + + parseNumber(stat.totalOpenIssues) + + parseNumber(stat.totalClosedIssues), + linesChanged: parseNumber(stat.totalNodesScored), + linesAdded: parseNumber(stat.totalAdditions), + linesDeleted: parseNumber(stat.totalDeletions), hotkey: stat.hotkey || 'N/A', - uniqueReposCount: Number(stat.uniqueReposCount) || 0, - credibility: Number(stat.issueCredibility) || 0, + uniqueReposCount: parseNumber(stat.uniqueReposCount), + issueCredibility: parseNumber(stat.issueCredibility), isEligible: stat.isIssueEligible ?? false, - usdPerDay: Number(stat.usdPerDay) || 0, - // Issue counts mapped to PR status fields - totalMergedPrs: Number(stat.totalSolvedIssues) || 0, - totalOpenPrs: Number(stat.totalOpenIssues) || 0, - totalClosedPrs: Number(stat.totalClosedIssues) || 0, + ossIsEligible: stat.isEligible ?? false, + discoveriesIsEligible: stat.isIssueEligible ?? false, + usdPerDay: parseNumber(stat.usdPerDay), + totalMergedPrs: parseNumber(stat.totalMergedPrs), + totalOpenPrs: parseNumber(stat.totalOpenPrs), + totalClosedPrs: parseNumber(stat.totalClosedPrs), + totalSolvedIssues: parseNumber(stat.totalSolvedIssues), + totalOpenIssues: parseNumber(stat.totalOpenIssues), + totalClosedIssues: parseNumber(stat.totalClosedIssues), })); }, [allMinersStats]); @@ -91,24 +98,11 @@ const DiscoveriesPage: React.FC = () => { overflow: showSidebarRight ? 'auto' : 'visible', minWidth: 0, pr: showSidebarRight ? 1 : 0, - '&::-webkit-scrollbar': { - width: '8px', - }, - '&::-webkit-scrollbar-track': { - backgroundColor: 'transparent', - }, - '&::-webkit-scrollbar-thumb': { - backgroundColor: 'rgba(255, 255, 255, 0.1)', - borderRadius: '4px', - '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.2)', - }, - }, + ...scrollbarSx, }} > alpha(t.palette.text.primary, 0.5), lineHeight: 1.6, @@ -121,8 +115,9 @@ const DiscoveriesPage: React.FC = () => {
@@ -141,7 +136,8 @@ const DiscoveriesPage: React.FC = () => { > diff --git a/src/pages/DiscoveryMinerDetailsPage.tsx b/src/pages/DiscoveryMinerDetailsPage.tsx deleted file mode 100644 index 650a3125..00000000 --- a/src/pages/DiscoveryMinerDetailsPage.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React from 'react'; -import { Navigate, useSearchParams } from 'react-router-dom'; -import { Box, Tab, Tabs } from '@mui/material'; -import { Page } from '../components/layout'; -import { - BackButton, - MinerActivity, - MinerInsightsCard, - MinerPRsTable, - MinerRepositoriesTable, - MinerScoreBreakdown, - MinerScoreCard, - IssueDiscoveryScoreCard, - SEO, -} from '../components'; - -const TAB_NAMES = [ - 'overview', - 'activity', - 'pull-requests', - 'repositories', -] as const; -type MinerDetailsTab = (typeof TAB_NAMES)[number]; - -const DiscoveryMinerDetailsPage: React.FC = () => { - const [searchParams, setSearchParams] = useSearchParams(); - const githubId = searchParams.get('githubId'); - - const tabParam = searchParams.get('tab'); - const activeTab: MinerDetailsTab = - tabParam && TAB_NAMES.includes(tabParam as MinerDetailsTab) - ? (tabParam as MinerDetailsTab) - : 'overview'; - - const handleTabChange = ( - _event: React.SyntheticEvent, - newValue: MinerDetailsTab, - ) => { - const newParams = new URLSearchParams(searchParams); - newParams.set('tab', newValue); - setSearchParams(newParams); - }; - - if (!githubId) { - return ; - } - - return ( - - - - - - - - - - - t.palette.text.secondary, - fontFamily: '"JetBrains Mono", monospace', - textTransform: 'none', - fontSize: '0.83rem', - fontWeight: 500, - '&.Mui-selected': { - color: 'primary.main', - }, - }, - }} - > - - - - - - - - - {activeTab === 'overview' && ( - <> - - - - )} - - {activeTab === 'activity' && } - {activeTab === 'pull-requests' && ( - - )} - {activeTab === 'repositories' && ( - - )} - - - - - ); -}; - -export default DiscoveryMinerDetailsPage; diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index f58a8945..3ac51302 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Box, Stack, Typography } from '@mui/material'; +import { Box, Stack, Typography, alpha } from '@mui/material'; import { Page } from '../components/layout'; import { SEO } from '../components'; import { useMonthlyRewards } from '../hooks/useMonthlyRewards'; @@ -30,22 +30,24 @@ const HomePage: React.FC = () => { justifyContent="center" gap={{ xs: 2, sm: 3 }} > - Gittensor ({ height: window.innerWidth < 600 ? '96px' : '128px', width: 'auto', - filter: - 'grayscale(100%) invert(1) drop-shadow(0 0 12px rgba(255, 255, 255, 0.8))', - }} + filter: `grayscale(100%) invert(1) drop-shadow(0 0 12px ${alpha( + theme.palette.text.primary, + 0.8, + )})`, + })} /> { {/* Monthly Rewards Banner */} {monthlyRewards && ( ({ mt: { xs: 4, sm: 5 }, px: { xs: 3, sm: 5, md: 7 }, py: { xs: 2.5, sm: 3.5 }, borderRadius: 2, - background: 'rgba(0, 0, 0, 0.4)', - border: '1px solid rgba(255, 255, 255, 0.15)', + background: alpha(theme.palette.background.default, 0.4), + border: '1px solid', + borderColor: theme.palette.border.light, backdropFilter: 'blur(10px)', - boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)', + boxShadow: `0 8px 32px ${alpha(theme.palette.background.default, 0.3)}`, transition: 'all 0.3s ease-in-out', '&:hover': { - border: '1px solid rgba(255, 255, 255, 0.25)', - boxShadow: '0 12px 48px rgba(0, 0, 0, 0.4)', + borderColor: theme.palette.border.medium, + boxShadow: `0 12px 48px ${alpha(theme.palette.background.default, 0.4)}`, transform: 'translateY(-2px)', }, - }} + })} > theme.palette.text.secondary, fontSize: { xs: '0.7rem', sm: '0.75rem' }, textTransform: 'uppercase', letterSpacing: '0.15em', @@ -99,10 +102,9 @@ const HomePage: React.FC = () => { theme.palette.text.primary, fontSize: { xs: '2rem', sm: '2.75rem', md: '3.5rem' }, letterSpacing: '-0.02em', }} @@ -115,8 +117,8 @@ const HomePage: React.FC = () => { theme.palette.text.tertiary, fontSize: { xs: '0.75rem', sm: '0.85rem' }, textAlign: 'center', maxWidth: '400px', diff --git a/src/pages/IssueDetailsPage.tsx b/src/pages/IssueDetailsPage.tsx index 06410d3a..8fc5dd81 100644 --- a/src/pages/IssueDetailsPage.tsx +++ b/src/pages/IssueDetailsPage.tsx @@ -9,7 +9,7 @@ import { Tab, } from '@mui/material'; import { Page } from '../components/layout'; -import { BackButton, SEO } from '../components'; +import { BackButton, SEO, WatchlistButton } from '../components'; import { IssueHeaderCard, IssueSubmissionsTable, @@ -90,15 +90,29 @@ const IssueDetailsPage: React.FC = () => { > - + + + + + + {/* Tabs */} - + ({ + borderBottom: 1, + borderColor: theme.palette.border.light, + })} + > ({ '& .MuiTab-root': { color: STATUS_COLORS.open, fontFamily: @@ -108,16 +122,16 @@ const IssueDetailsPage: React.FC = () => { minHeight: '48px', fontSize: '14px', '&.Mui-selected': { - color: 'text.primary', + color: theme.palette.text.primary, fontWeight: 600, }, }, '& .MuiTabs-indicator': { - backgroundColor: 'primary.main', + backgroundColor: theme.palette.primary.main, height: '3px', borderRadius: '3px 3px 0 0', }, - }} + })} > `/bounties/details?id=${id}`; const IssuesPage: React.FC = () => { const navigate = useNavigate(); const { tab: tabParam } = useParams<{ tab?: string }>(); - const tabIndex = Math.max( - 0, - TAB_SLUGS.indexOf(tabParam as (typeof TAB_SLUGS)[number]), - ); + // Redirect legacy path-based tabs to query params + useEffect(() => { + if ( + tabParam === 'available' || + tabParam === 'pending' || + tabParam === 'history' + ) { + navigate(`/bounties?filter=${tabParam}`, { replace: true }); + } + }, [tabParam, navigate]); const statsQuery = useIssuesStats(); const activeIssuesQuery = useIssues('active'); const registeredIssuesQuery = useIssues('registered'); const historyIssuesQuery = useIssues('completed,cancelled'); - const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { - navigate(`/bounties/${TAB_SLUGS[newValue]}`, { replace: true }); - }; + const allIssues = useMemo(() => { + const seen = new Set(); + const result = []; + for (const issue of [ + ...(activeIssuesQuery.data || []), + ...(registeredIssuesQuery.data || []), + ...(historyIssuesQuery.data || []), + ]) { + if (!seen.has(issue.id)) { + seen.add(issue.id); + result.push(issue); + } + } + return result; + }, [ + activeIssuesQuery.data, + registeredIssuesQuery.data, + historyIssuesQuery.data, + ]); + + // Show loading skeleton only while no data is available yet + const isLoading = + activeIssuesQuery.isLoading && + registeredIssuesQuery.isLoading && + historyIssuesQuery.isLoading; return ( @@ -51,84 +73,48 @@ const IssuesPage: React.FC = () => { }} > - {/* Stats Header */} - {/* Tabs Navigation */} - alpha(t.palette.text.primary, 0.35), + pr: 1, + mt: { xs: 0.5, md: 0 }, + textAlign: 'right', }} > - - - - - - + docs + + - {/* Tab Content */} - - {tabIndex === 0 && ( - - navigate(`/bounties/details?id=${id}`, { - state: { backLabel: 'Back to Bounties' }, - }) - } - /> - )} - {tabIndex === 1 && ( - - navigate(`/bounties/details?id=${id}`, { - state: { backLabel: 'Back to Bounties' }, - }) - } - /> - )} - {tabIndex === 2 && ( - - navigate(`/bounties/details?id=${id}`, { - state: { backLabel: 'Back to Bounties' }, - }) - } - /> - )} - + diff --git a/src/pages/MinerDetailsPage.tsx b/src/pages/MinerDetailsPage.tsx index 96f68ae5..0ebc9c5c 100644 --- a/src/pages/MinerDetailsPage.tsx +++ b/src/pages/MinerDetailsPage.tsx @@ -1,7 +1,8 @@ import React from 'react'; -import { Navigate, useSearchParams } from 'react-router-dom'; -import { Box, Tab, Tabs } from '@mui/material'; +import { Navigate, useSearchParams, useLocation } from 'react-router-dom'; +import { Box, Tab, Tabs, Typography, alpha } from '@mui/material'; import { Page } from '../components/layout'; +import { LinkBox } from '../components/common/linkBehavior'; import { BackButton, MinerActivity, @@ -12,22 +13,59 @@ import { MinerScoreCard, SEO, } from '../components'; +import { WatchlistButton } from '../components/common'; -const TAB_NAMES = [ +type ViewMode = 'prs' | 'issues'; + +const PR_TABS = [ 'overview', 'activity', 'pull-requests', 'repositories', ] as const; -type MinerDetailsTab = (typeof TAB_NAMES)[number]; +const ISSUE_TABS = ['overview', 'activity', 'repositories'] as const; +type MinerDetailsTab = (typeof PR_TABS)[number] | (typeof ISSUE_TABS)[number]; + +/** + * Align first tab label with Card body content (MinerInsightsCard `p: 3` — same edge as + * "Insights & Next Actions" and insight row borders, not inner `px: 1.5` text). + * Padding lives on the tab flex row, not the scroll buttons: with scroll arrows, the + * first tab was shifted right by the left arrow width. + */ +const tabsAlignSx = { + '& .MuiTabs-flexContainer': { + pl: 3, + }, + '& .MuiTab-root': { + color: 'text.secondary', + textTransform: 'none' as const, + fontSize: '0.83rem', + fontWeight: 500, + '&.Mui-selected': { color: 'primary.main' }, + '&:first-of-type': { pl: 0 }, + }, +}; const MinerDetailsPage: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); + const location = useLocation(); const githubId = searchParams.get('githubId'); + const buildModeHref = (mode: ViewMode) => { + const p = new URLSearchParams(searchParams); + p.set('mode', mode); + p.set('tab', 'overview'); + return `${location.pathname}?${p.toString()}`; + }; + + const modeParam = searchParams.get('mode'); + const viewMode: ViewMode = modeParam === 'issues' ? 'issues' : 'prs'; + + const tabs = viewMode === 'issues' ? ISSUE_TABS : PR_TABS; + const tabParam = searchParams.get('tab'); const activeTab: MinerDetailsTab = - tabParam && TAB_NAMES.includes(tabParam as MinerDetailsTab) + tabParam && (tabs as readonly string[]).includes(tabParam) ? (tabParam as MinerDetailsTab) : 'overview'; @@ -35,9 +73,9 @@ const MinerDetailsPage: React.FC = () => { _event: React.SyntheticEvent, newValue: MinerDetailsTab, ) => { - const newParams = new URLSearchParams(searchParams); - newParams.set('tab', newValue); - setSearchParams(newParams); + const p = new URLSearchParams(searchParams); + p.set('tab', newValue); + setSearchParams(p, { replace: true }); }; if (!githubId) { @@ -48,7 +86,7 @@ const MinerDetailsPage: React.FC = () => { { px: { xs: 2, md: 0 }, }} > - - - - + + + + + + {( + [ + { label: 'OSS Contributions', value: 'prs' as const }, + { label: 'Issue Discovery', value: 'issues' as const }, + ] as const + ).map((option) => { + const isActive = viewMode === option.value; + return ( + alpha(t.palette.text.primary, 0.5), + transition: 'all 0.2s', + '&:hover': { + backgroundColor: 'surface.elevated', + color: 'text.primary', + }, + }} + > + + {option.label} + + + ); + })} + + + + + + t.palette.text.secondary, - fontFamily: '"JetBrains Mono", monospace', - textTransform: 'none', - fontSize: '0.83rem', - fontWeight: 500, - '&.Mui-selected': { - color: 'primary.main', - }, - }, - }} + scrollButtons={false} + sx={tabsAlignSx} > - + {viewMode === 'prs' && ( + + )} @@ -108,12 +210,14 @@ const MinerDetailsPage: React.FC = () => { {activeTab === 'overview' && ( <> - - + + )} - {activeTab === 'activity' && } + {activeTab === 'activity' && ( + + )} {activeTab === 'pull-requests' && ( )} diff --git a/src/pages/NotFoundPage.tsx b/src/pages/NotFoundPage.tsx index 18dad412..b9a83460 100644 --- a/src/pages/NotFoundPage.tsx +++ b/src/pages/NotFoundPage.tsx @@ -1,28 +1,101 @@ import React from 'react'; -import { FaceFrownIcon } from '@heroicons/react/24/outline'; -import { Stack, Typography } from '@mui/material'; +import { scrollbarSx } from '../theme'; +import { Box, Button, Stack, Typography, alpha } from '@mui/material'; +import SearchOffIcon from '@mui/icons-material/SearchOff'; +import HomeIcon from '@mui/icons-material/Home'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import { useNavigate, useLocation } from 'react-router-dom'; -const NotFoundPage: React.FC = () => ( - - 404 Not Found - { + const navigate = useNavigate(); + const location = useLocation(); + + return ( + - -); + > + + alpha(t.palette.text.primary, 0.3), + }} + /> + + Page not found + + alpha(t.palette.text.primary, 0.5), + lineHeight: 1.6, + }} + > + The URL you followed doesn't match any page. It may have been moved or + removed. + + alpha(t.palette.text.primary, 0.4), + border: '1px solid', + borderColor: 'border.light', + borderRadius: 1, + p: 1.5, + m: 0, + overflow: 'auto', + whiteSpace: 'pre-wrap', + wordBreak: 'break-all', + ...scrollbarSx, + }} + > + {location.pathname} + {location.search} + + + + + + + + ); +}; export default NotFoundPage; diff --git a/src/pages/OnboardPage.tsx b/src/pages/OnboardPage.tsx index 2186fec4..4f653a25 100644 --- a/src/pages/OnboardPage.tsx +++ b/src/pages/OnboardPage.tsx @@ -3,14 +3,12 @@ import { Box, Tabs, Tab, Card, CardContent } from '@mui/material'; import { Page } from '../components/layout'; import { SEO } from '../components'; import { useSearchParams } from 'react-router-dom'; -import { AboutContent } from './AboutPage'; +import { AboutContent } from '../components/onboard/AboutContent'; +import { FAQContent } from '../components/onboard/FAQContent'; import { GettingStarted } from '../components/onboard/GettingStarted'; import { Scoring } from '../components/onboard/Scoring'; -import { - RepositoryWeightsTable, - LanguageWeightsTable, -} from '../components/repositories'; +import { LanguageWeightsTable } from '../components/repositories'; const OnboardPage: React.FC = () => { const [searchParams, setSearchParams] = useSearchParams(); @@ -21,16 +19,16 @@ const OnboardPage: React.FC = () => { about: 0, 'getting-started': 1, scoring: 2, - repositories: 3, - languages: 4, + languages: 3, + faq: 4, }; const indexToTabName: Record = { 0: 'about', 1: 'getting-started', 2: 'scoring', - 3: 'repositories', - 4: 'languages', + 3: 'languages', + 4: 'faq', }; const activeTab = @@ -41,7 +39,7 @@ const OnboardPage: React.FC = () => { const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { const newParams = new URLSearchParams(searchParams); newParams.set('tab', indexToTabName[newValue]); - setSearchParams(newParams); + setSearchParams(newParams, { replace: true }); }; return ( @@ -61,12 +59,13 @@ const OnboardPage: React.FC = () => { }} > ({ maxWidth: 1200, width: '100%', mb: 4, - borderBottom: '1px solid rgba(255, 255, 255, 0.1)', - }} + borderBottom: '1px solid', + borderColor: theme.palette.border.light, + })} > { variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile - sx={{ + sx={(theme) => ({ px: 2, '& .MuiTab-root': { textTransform: 'none', fontWeight: 500, fontSize: '1rem', - color: 'rgba(255, 255, 255, 0.6)', + color: theme.palette.text.secondary, '&.Mui-selected': { - color: 'primary.main', + color: theme.palette.primary.main, }, }, - }} + })} > - + @@ -109,38 +108,14 @@ const OnboardPage: React.FC = () => { }} > ({ borderRadius: 3, - border: '1px solid rgba(255, 255, 255, 0.1)', - backgroundColor: 'transparent', + border: '1px solid', + borderColor: theme.palette.border.light, + backgroundColor: theme.palette.surface.transparent, maxWidth: 1200, width: '100%', - }} - elevation={0} - > - - - - - - )} - {activeTab === 4 && ( - - @@ -149,6 +124,7 @@ const OnboardPage: React.FC = () => { )} + {activeTab === 4 && } diff --git a/src/pages/PRDetailsPage.tsx b/src/pages/PRDetailsPage.tsx index 7d1043f6..b4bc76cf 100644 --- a/src/pages/PRDetailsPage.tsx +++ b/src/pages/PRDetailsPage.tsx @@ -9,8 +9,10 @@ import { BackButton, SEO, PRComments, + WatchlistButton, } from '../components'; import { usePullRequestDetails } from '../api'; +import { serializePRKey } from '../hooks/useWatchlist'; import { STATUS_COLORS } from '../theme'; import VisibilityIcon from '@mui/icons-material/Visibility'; import CodeIcon from '@mui/icons-material/Code'; @@ -29,10 +31,10 @@ const PRDetailsPage: React.FC = () => { pullRequestNumber ? parseInt(pullRequestNumber) : 0, ); - // If no repo or PR number is provided, redirect to miners page + // If no repo or PR number is provided, redirect to OSS contributors (registered route) if (!repository || !pullRequestNumber) { if (typeof window !== 'undefined') { - navigate('/miners?tab=prs'); + navigate('/top-miners', { replace: true }); } return null; } @@ -100,19 +102,36 @@ const PRDetailsPage: React.FC = () => { {/* Header always visible */} - + + + + + + {/* Tabs */} - + ({ + borderBottom: 1, + borderColor: theme.palette.border.light, + })} + > ({ '& .MuiTab-root': { color: STATUS_COLORS.open, fontFamily: @@ -122,16 +141,16 @@ const PRDetailsPage: React.FC = () => { minHeight: '48px', fontSize: '14px', '&.Mui-selected': { - color: '#fff', + color: theme.palette.text.primary, fontWeight: 600, }, }, '& .MuiTabs-indicator': { - backgroundColor: 'primary.main', + backgroundColor: theme.palette.primary.main, height: '3px', borderRadius: '3px 3px 0 0', }, - }} + })} > void; + href: string; + linkState?: Record; avatar: string; - avatarBg?: string; + avatarBg?: (theme: Theme) => string; label: React.ReactNode; right: React.ReactNode; -}> = ({ onClick, avatar, avatarBg = 'transparent', label, right }) => ( - - = ({ href, linkState, avatar, avatarBg = 'transparent', label, right }) => { + return ( + - - {label} - - {right} - -); + > + + {label} + + {right} + + ); +}; const getAvatarBg = (name: string) => { const owner = name.split('/')[0]; - if (owner === 'opentensor') return '#ffffff'; - if (owner === 'bitcoin') return '#F7931A'; - return 'transparent'; + if (owner === 'opentensor') + return (theme: Theme) => theme.palette.text.primary; + if (owner === 'bitcoin') + return (theme: Theme) => theme.palette.status.warningOrange; + return (theme: Theme) => theme.palette.surface.transparent; }; const SectionHeader: React.FC<{ children: React.ReactNode }> = ({ children, }) => ( ({ fontFamily: FONTS.mono, fontSize: '0.75rem', fontWeight: 600, - color: 'rgba(255,255,255,0.5)', + color: theme.palette.text.secondary, textTransform: 'uppercase', letterSpacing: '0.05em', mb: 1.5, pb: 1, - borderBottom: '1px solid rgba(255,255,255,0.05)', - }} + borderBottom: '1px solid', + borderColor: theme.palette.border.subtle, + })} > {children} ); -const cardSx = { +const cardSx = (theme: Theme) => ({ p: 2, borderRadius: 2, - border: '1px solid rgba(255, 255, 255, 0.1)', - backgroundColor: 'transparent', + border: '1px solid', + borderColor: theme.palette.border.light, + backgroundColor: theme.palette.surface.transparent, display: 'flex', flexDirection: 'column' as const, transition: 'all 0.2s', '&:hover': { - backgroundColor: 'rgba(255, 255, 255, 0.04)', - borderColor: 'rgba(255, 255, 255, 0.15)', + backgroundColor: theme.palette.surface.light, + borderColor: theme.palette.border.medium, }, -}; +}); // ── Page ──────────────────────────────────────────────────────────────────── -const RepositoriesPage: React.FC = () => { - const navigate = useNavigate(); +const REPO_LINK_STATE = { backLabel: 'Back to Repositories' } as const; +const getRepoHref = (name: string) => + `/miners/repository?name=${encodeURIComponent(name)}`; +const getPrHref = (name: string, number: number) => + `/miners/pr?repo=${encodeURIComponent(name)}&number=${number}`; +const RepositoriesPage: React.FC = () => { const formatRelativeTime = (date: Date) => { const now = new Date(); if (date > now) return 'just now'; @@ -128,13 +141,6 @@ const RepositoriesPage: React.FC = () => { const isLoading = isLoadingPRs || isLoadingRepos; - const handleSelectRepository = (repositoryFullName: string) => { - navigate( - `/miners/repository?name=${encodeURIComponent(repositoryFullName)}`, - { state: { backLabel: 'Back to Repositories' } }, - ); - }; - // ── Main table stats ──────────────────────────────────────────────────── const repoStats = useMemo(() => { if (!reposWithWeights) return []; @@ -195,8 +201,9 @@ const RepositoriesPage: React.FC = () => { const prDate = pr.mergedAt; if (!pr?.repository || !prDate) return; const score = parseFloat(pr.score || '0'); + const repoKey = pr.repository.toLowerCase(); - const cur = repoScores.get(pr.repository) || { + const cur = repoScores.get(repoKey) || { recentScore: 0, priorScore: 0, recentPRs: 0, @@ -208,18 +215,19 @@ const RepositoriesPage: React.FC = () => { } else { cur.priorScore += score; } - repoScores.set(pr.repository, cur); + repoScores.set(repoKey, cur); }); - const repoMap = new Map(reposWithWeights.map((r) => [r.fullName, r])); + const repoMap = new Map( + reposWithWeights.map((r) => [r.fullName.toLowerCase(), r]), + ); return Array.from(repoScores.entries()) .filter( - ([name, s]) => - repoMap.has(name) && s.recentScore > 0 && s.priorScore > 0, + ([key, s]) => repoMap.has(key) && s.recentScore > 0 && s.priorScore > 0, ) - .map(([name, s]) => ({ - name, + .map(([key, s]) => ({ + name: repoMap.get(key)!.fullName, recentScore: s.recentScore, priorScore: s.priorScore, pctIncrease: (s.recentScore / s.priorScore) * 100, @@ -233,7 +241,9 @@ const RepositoriesPage: React.FC = () => { const topCollateralRepos = useMemo(() => { if (!allPRs || !reposWithWeights) return []; - const repoMap = new Map(reposWithWeights.map((r) => [r.fullName, r])); + const repoMap = new Map( + reposWithWeights.map((r) => [r.fullName.toLowerCase(), r]), + ); // Sum collateral from open PRs per repo const repoCollateral = new Map< @@ -243,22 +253,23 @@ const RepositoriesPage: React.FC = () => { allPRs.forEach((pr: CommitLog) => { if (!pr?.repository || pr.prState !== 'OPEN') return; - if (!repoMap.has(pr.repository)) return; + const repoKey = pr.repository.toLowerCase(); + if (!repoMap.has(repoKey)) return; const collateral = parseFloat(pr.collateralScore || '0'); if (collateral <= 0) return; - const cur = repoCollateral.get(pr.repository) || { + const cur = repoCollateral.get(repoKey) || { totalCollateral: 0, openPRs: 0, }; cur.totalCollateral += collateral; cur.openPRs += 1; - repoCollateral.set(pr.repository, cur); + repoCollateral.set(repoKey, cur); }); return Array.from(repoCollateral.entries()) - .map(([name, data]) => ({ - name, + .map(([key, data]) => ({ + name: repoMap.get(key)!.fullName, collateral: data.totalCollateral, openPRs: data.openPRs, })) @@ -270,7 +281,9 @@ const RepositoriesPage: React.FC = () => { const recentPrs = useMemo(() => { if (!allPRs || !reposWithWeights) return []; - const repoMap = new Map(reposWithWeights.map((r) => [r.fullName, r])); + const repoMap = new Map( + reposWithWeights.map((r) => [r.fullName.toLowerCase(), r]), + ); const today = new Date(); today.setHours(0, 0, 0, 0); @@ -280,7 +293,7 @@ const RepositoriesPage: React.FC = () => { (pr) => pr.repository && pr.mergedAt && - repoMap.has(pr.repository) && + repoMap.has(pr.repository.toLowerCase()) && new Date(pr.mergedAt) >= today, ) .sort((a, b) => { @@ -289,10 +302,10 @@ const RepositoriesPage: React.FC = () => { if (scoreB !== scoreA) return scoreB - scoreA; // Tiebreak by repo weight const weightA = parseFloat( - String(repoMap.get(a.repository)?.weight || '0'), + String(repoMap.get(a.repository?.toLowerCase() ?? '')?.weight || '0'), ); const weightB = parseFloat( - String(repoMap.get(b.repository)?.weight || '0'), + String(repoMap.get(b.repository?.toLowerCase() ?? '')?.weight || '0'), ); return weightB - weightA; }) @@ -339,12 +352,12 @@ const RepositoriesPage: React.FC = () => { {trendingRepos.length === 0 && !isLoading ? ( ({ + color: alpha(theme.palette.text.primary, 0.3), fontSize: '0.8rem', fontStyle: 'italic', p: 1, - }} + })} > No data available @@ -352,7 +365,8 @@ const RepositoriesPage: React.FC = () => { trendingRepos.map((repo) => ( handleSelectRepository(repo.name)} + href={getRepoHref(repo.name)} + linkState={REPO_LINK_STATE} avatar={`https://avatars.githubusercontent.com/${repo.name.split('/')[0]}`} avatarBg={getAvatarBg(repo.name)} label={ @@ -361,7 +375,7 @@ const RepositoriesPage: React.FC = () => { sx={{ fontFamily: FONTS.mono, fontSize: '0.82rem', - color: 'rgba(255,255,255,0.9)', + color: 'text.primary', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -373,17 +387,20 @@ const RepositoriesPage: React.FC = () => { } right={ ({ fontFamily: FONTS.mono, fontSize: '0.75rem', fontWeight: 600, - color: '#51cf66', + color: theme.palette.status.success, flexShrink: 0, - backgroundColor: 'rgba(81, 207, 102, 0.1)', + backgroundColor: alpha( + theme.palette.status.success, + 0.1, + ), px: 0.75, py: 0.25, borderRadius: '4px', - }} + })} > +{repo.pctIncrease.toFixed(0)}% @@ -404,12 +421,12 @@ const RepositoriesPage: React.FC = () => { {topCollateralRepos.length === 0 && !isLoading ? ( ({ + color: alpha(theme.palette.text.primary, 0.3), fontSize: '0.8rem', fontStyle: 'italic', p: 1, - }} + })} > No collateral data available @@ -417,7 +434,8 @@ const RepositoriesPage: React.FC = () => { topCollateralRepos.map((repo) => ( handleSelectRepository(repo.name)} + href={getRepoHref(repo.name)} + linkState={REPO_LINK_STATE} avatar={`https://avatars.githubusercontent.com/${repo.name.split('/')[0]}`} avatarBg={getAvatarBg(repo.name)} label={ @@ -426,7 +444,7 @@ const RepositoriesPage: React.FC = () => { sx={{ fontFamily: FONTS.mono, fontSize: '0.82rem', - color: 'rgba(255,255,255,0.9)', + color: 'text.primary', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -441,7 +459,7 @@ const RepositoriesPage: React.FC = () => { sx={{ fontFamily: FONTS.mono, fontSize: '0.72rem', - color: 'rgba(255,255,255,0.7)', + color: 'text.secondary', flexShrink: 0, whiteSpace: 'nowrap', }} @@ -466,12 +484,12 @@ const RepositoriesPage: React.FC = () => { {recentPrs.length === 0 && !isLoading ? ( ({ + color: alpha(theme.palette.text.primary, 0.3), fontSize: '0.8rem', fontStyle: 'italic', p: 1, - }} + })} > No data available @@ -479,12 +497,8 @@ const RepositoriesPage: React.FC = () => { recentPrs.map((pr) => ( - navigate( - `/miners/pr?repo=${encodeURIComponent(pr.name)}&number=${pr.number}`, - { state: { backLabel: 'Back to Repositories' } }, - ) - } + href={getPrHref(pr.name, pr.number)} + linkState={REPO_LINK_STATE} avatar={`https://avatars.githubusercontent.com/${pr.name.split('/')[0]}`} avatarBg={getAvatarBg(pr.name)} label={ @@ -501,7 +515,7 @@ const RepositoriesPage: React.FC = () => { sx={{ fontFamily: FONTS.mono, fontSize: '0.68rem', - color: 'rgba(255,255,255,0.45)', + color: 'text.tertiary', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -516,7 +530,7 @@ const RepositoriesPage: React.FC = () => { sx={{ fontFamily: FONTS.mono, fontSize: '0.78rem', - color: 'rgba(255,255,255,0.9)', + color: 'text.primary', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', @@ -530,14 +544,14 @@ const RepositoriesPage: React.FC = () => { } right={ ({ fontFamily: FONTS.mono, fontSize: '0.68rem', - color: 'rgba(255,255,255,0.35)', + color: alpha(theme.palette.text.primary, 0.35), flexShrink: 0, whiteSpace: 'nowrap', ml: 1, - }} + })} > {formatRelativeTime(pr.createdAt)} @@ -553,18 +567,20 @@ const RepositoriesPage: React.FC = () => { {/* ── Main Table ────────────────────────────────────────────── */} ({ borderRadius: 3, - border: '1px solid rgba(255, 255, 255, 0.1)', - backgroundColor: 'transparent', + border: '1px solid', + borderColor: theme.palette.border.light, + backgroundColor: theme.palette.surface.transparent, overflow: 'hidden', - }} + })} elevation={0} > diff --git a/src/pages/RepositoryDetailsPage.tsx b/src/pages/RepositoryDetailsPage.tsx index 5859743e..07327925 100644 --- a/src/pages/RepositoryDetailsPage.tsx +++ b/src/pages/RepositoryDetailsPage.tsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react'; +import React from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; +import { formatDate } from '../utils/format'; import { Alert, Box, @@ -36,6 +37,7 @@ import { ContributingViewer, RepositoryMaintainers, RepositoryCheckTab, + WatchlistButton, } from '../components'; interface TabPanelProps { @@ -60,21 +62,39 @@ function CustomTabPanel(props: TabPanelProps) { ); } +/** Synced to the `tab` query param so back navigation from PR details restores the active tab. */ +const REPO_TAB_KEYS = [ + 'readme', + 'code', + 'issues', + 'pull-requests', + 'contributing', + 'repo-check', +] as const; + +function tabIndexFromSearchParam(tab: string | null): number { + if (!tab) return 0; + const idx = REPO_TAB_KEYS.indexOf(tab as (typeof REPO_TAB_KEYS)[number]); + return idx >= 0 ? idx : 0; +} + const RepositoryDetailsPage: React.FC = () => { - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const navigate = useNavigate(); const repo = searchParams.get('name'); - const [tabValue, setTabValue] = useState(0); + const tabValue = tabIndexFromSearchParam(searchParams.get('tab')); const { data: repos, isLoading: isLoadingRepos } = useReposAndWeights(); const { data: bountySummary } = useRepoBountySummary(repo || ''); - const trackedRepo = repos?.find((r) => r.fullName === repo); + const trackedRepo = repos?.find( + (r) => r.fullName.toLowerCase() === (repo ?? '').toLowerCase(), + ); const isTrackedRepository = Boolean(trackedRepo); const owner = repo ? repo.split('/')[0] : ''; - // If no repo is provided, redirect to miners page + // If no repo is provided, redirect to repository list (registered route) if (!repo) { - navigate('/miners'); + navigate('/repositories', { replace: true }); return null; } @@ -101,13 +121,14 @@ const RepositoryDetailsPage: React.FC = () => { ({ mt: 2, - backgroundColor: 'rgba(245, 124, 0, 0.08)', - border: '1px solid rgba(255, 183, 77, 0.3)', - color: '#ffcc80', - '& .MuiAlert-icon': { color: '#ffb74d' }, - }} + backgroundColor: alpha(STATUS_COLORS.warningOrange, 0.08), + border: '1px solid', + borderColor: alpha(STATUS_COLORS.warningOrange, 0.3), + color: alpha(theme.palette.text.primary, 0.8), + '& .MuiAlert-icon': { color: theme.palette.status.warningOrange }, + })} > This repository is not tracked by Gittensor. @@ -123,7 +144,20 @@ const RepositoryDetailsPage: React.FC = () => { } const handleTabChange = (_event: React.SyntheticEvent, newValue: number) => { - setTabValue(newValue); + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (!repo) return next; + next.set('name', repo); + if (newValue === 0) { + next.delete('tab'); + } else { + next.set('tab', REPO_TAB_KEYS[newValue]); + } + return next; + }, + { replace: true }, + ); }; return ( @@ -135,10 +169,11 @@ const RepositoryDetailsPage: React.FC = () => { {/* Header Section */} ({ + borderBottom: '1px solid', + borderColor: theme.palette.border.light, + backgroundColor: theme.palette.surface.subtle, + })} > @@ -155,39 +190,39 @@ const RepositoryDetailsPage: React.FC = () => { ({ width: 32, height: 32, borderRadius: '4px', backgroundColor: owner === 'opentensor' - ? '#ffffff' + ? theme.palette.text.primary : owner === 'bitcoin' - ? '#F7931A' - : 'transparent', - }} + ? theme.palette.status.warningOrange + : theme.palette.surface.transparent, + })} /> ({ fontWeight: 600, - color: '#fff', - }} + color: theme.palette.text.primary, + })} > {repo} + ({ + backgroundColor: alpha(STATUS_COLORS.success, 0.15), + color: theme.palette.status.success, + border: `1px solid ${alpha(STATUS_COLORS.success, 0.35)}`, fontSize: '0.75rem', height: '24px', fontWeight: 600, - }} + })} /> {(() => { const currentRepo = trackedRepo; @@ -195,15 +230,15 @@ const RepositoryDetailsPage: React.FC = () => { if (currentRepo?.inactiveAt) { return ( ({ + backgroundColor: alpha(STATUS_COLORS.error, 0.1), + color: theme.palette.status.error, + border: `1px solid ${alpha(STATUS_COLORS.error, 0.3)}`, fontSize: '0.75rem', height: '24px', fontWeight: 600, - }} + })} /> ); } @@ -225,11 +260,12 @@ const RepositoryDetailsPage: React.FC = () => { startIcon={} href={`https://github.com/${repo}`} target="_blank" - sx={{ - borderColor: 'rgba(255,255,255,0.2)', - color: '#fff', - '&:hover': { borderColor: 'primary.main' }, - }} + rel="noopener noreferrer" + sx={(theme) => ({ + borderColor: theme.palette.border.medium, + color: theme.palette.text.primary, + '&:hover': { borderColor: theme.palette.primary.main }, + })} > View on GitHub @@ -240,7 +276,7 @@ const RepositoryDetailsPage: React.FC = () => { value={tabValue} onChange={handleTabChange} aria-label="repository tabs" - sx={{ + sx={(theme) => ({ '& .MuiTab-root': { color: STATUS_COLORS.open, fontFamily: @@ -250,16 +286,16 @@ const RepositoryDetailsPage: React.FC = () => { minHeight: '48px', fontSize: '14px', '&.Mui-selected': { - color: '#fff', + color: theme.palette.text.primary, fontWeight: 600, }, }, '& .MuiTabs-indicator': { - backgroundColor: 'primary.main', + backgroundColor: theme.palette.primary.main, height: '3px', borderRadius: '3px 3px 0 0', }, - }} + })} > } @@ -299,7 +335,6 @@ const RepositoryDetailsPage: React.FC = () => { px: 0.8, py: 0.1, borderRadius: '10px', - fontFamily: '"JetBrains Mono", monospace', lineHeight: 1.4, }} > diff --git a/src/pages/TopMinersPage.tsx b/src/pages/TopMinersPage.tsx index d58e8ec4..d7703308 100644 --- a/src/pages/TopMinersPage.tsx +++ b/src/pages/TopMinersPage.tsx @@ -1,57 +1,30 @@ import React, { useMemo } from 'react'; import { useMediaQuery, Box, Typography, alpha } from '@mui/material'; -import { useNavigate } from 'react-router-dom'; import { Page } from '../components/layout'; -import { TopMinersTable, LeaderboardSidebar, SEO } from '../components'; +import { + TopMinersTable, + LeaderboardSidebar, + SEO, + type MinerStats, +} from '../components'; import { useAllMiners } from '../api'; -import { parseNumber } from '../utils'; -import theme from '../theme'; +import { mapAllMinersToStats } from '../utils/minerMapper'; +import theme, { scrollbarSx } from '../theme'; -const TopMinersPage: React.FC = () => { - const navigate = useNavigate(); +const MINER_LINK_STATE = { backLabel: 'Back to Leaderboard' } as const; +const getMinerHref = (miner: MinerStats) => + `/miners/details?githubId=${miner.githubId}`; +const TopMinersPage: React.FC = () => { const allMinerStatsQuery = useAllMiners(); const allMinersStats = allMinerStatsQuery?.data; const isLoadingMinerStats = allMinerStatsQuery?.isLoading; - const handleSelectMiner = (githubId: string) => { - navigate(`/miners/details?githubId=${githubId}`, { - state: { backLabel: 'Back to Leaderboard' }, - }); - }; - - // Normalize leaderboard miner data. - const minerStats = useMemo(() => { - if (!Array.isArray(allMinersStats)) return []; - - const rankById = new Map( - [...allMinersStats] - .sort((a, b) => Number(b.totalScore) - Number(a.totalScore)) - .map((stat, index) => [String(stat.id), index + 1]), - ); - - return allMinersStats.map((stat) => ({ - id: String(stat.id), - githubId: stat.githubId || '', - author: stat.githubUsername || undefined, - totalScore: parseNumber(stat.totalScore), - baseTotalScore: parseNumber(stat.baseTotalScore), - totalPRs: parseNumber(stat.totalPrs), - linesChanged: parseNumber(stat.totalNodesScored), - linesAdded: parseNumber(stat.totalAdditions), - linesDeleted: parseNumber(stat.totalDeletions), - hotkey: stat.hotkey || 'N/A', - rank: rankById.get(String(stat.id)), - uniqueReposCount: parseNumber(stat.uniqueReposCount), - credibility: parseNumber(stat.credibility), - isEligible: stat.isEligible ?? false, - usdPerDay: parseNumber(stat.usdPerDay), - // PR status counts for credibility donut - totalMergedPrs: parseNumber(stat.totalMergedPrs), - totalOpenPrs: parseNumber(stat.totalOpenPrs), - totalClosedPrs: parseNumber(stat.totalClosedPrs), - })); - }, [allMinersStats]); + const minerStats = useMemo( + () => + Array.isArray(allMinersStats) ? mapAllMinersToStats(allMinersStats) : [], + [allMinersStats], + ); // Dashboard-like responsive logic const isMobile = useMediaQuery(theme.breakpoints.down('sm')); @@ -83,7 +56,7 @@ const TopMinersPage: React.FC = () => { > {/* Main Content Area */} ({ + sx={{ flex: 1, display: 'flex', flexDirection: 'column', @@ -92,24 +65,11 @@ const TopMinersPage: React.FC = () => { overflow: showSidebarRight ? 'auto' : 'visible', minWidth: 0, pr: showSidebarRight ? 1 : 0, - '&::-webkit-scrollbar': { - width: '8px', - }, - '&::-webkit-scrollbar-track': { - backgroundColor: 'transparent', - }, - '&::-webkit-scrollbar-thumb': { - backgroundColor: theme.palette.border.light, - borderRadius: '4px', - '&:hover': { - backgroundColor: theme.palette.border.medium, - }, - }, - })} + ...scrollbarSx, + }} > alpha(t.palette.text.primary, 0.5), lineHeight: 1.6, @@ -123,7 +83,8 @@ const TopMinersPage: React.FC = () => { @@ -143,7 +104,8 @@ const TopMinersPage: React.FC = () => { {/* Render extracted Sidebar Content here */} diff --git a/src/pages/WatchlistPage.tsx b/src/pages/WatchlistPage.tsx new file mode 100644 index 00000000..cf13bed8 --- /dev/null +++ b/src/pages/WatchlistPage.tsx @@ -0,0 +1,624 @@ +import React, { useMemo, useState } from 'react'; +import { + Box, + Typography, + Button, + alpha, + Stack, + Dialog, + DialogTitle, + DialogActions, + Tab, + Tabs, + Badge, + useMediaQuery, +} from '@mui/material'; +import { Link as RouterLink, useSearchParams } from 'react-router-dom'; +import { Page } from '../components/layout'; +import { + TopMinersTable, + ActivitySidebarCards, + SEO, + WatchlistButton, +} from '../components'; +import { LinkBox } from '../components/common/linkBehavior'; +import { useAllMiners, useAllPrs, useReposAndWeights, useIssues } from '../api'; +import { mapAllMinersToStats } from '../utils/minerMapper'; +import { + useWatchlist, + useWatchlistCounts, + serializePRKey, + type WatchlistCategory, +} from '../hooks/useWatchlist'; +import { isMergedPr, isClosedUnmergedPr } from '../utils/prStatus'; +import { getIssueStatusMeta } from '../utils/issueStatus'; +import { formatTokenAmount } from '../utils/format'; +import theme, { STATUS_COLORS, scrollbarSx } from '../theme'; + +const TAB_ORDER: readonly WatchlistCategory[] = [ + 'miners', + 'repos', + 'bounties', + 'prs', +] as const; + +const TAB_LABELS: Record = { + miners: 'Miners', + repos: 'Repositories', + bounties: 'Bounties', + prs: 'Pull Requests', +}; + +const TAB_NOUN: Record = + { + miners: { single: 'miner', plural: 'miners' }, + repos: { single: 'repository', plural: 'repositories' }, + bounties: { single: 'bounty', plural: 'bounties' }, + prs: { single: 'pull request', plural: 'pull requests' }, + }; + +const TAB_DISCOVERY: Record< + WatchlistCategory, + { label: string; path: string; hint: string } +> = { + miners: { + label: 'leaderboard', + path: '/top-miners', + hint: 'Browse the leaderboard and star miners you want to track.', + }, + repos: { + label: 'repositories', + path: '/repositories', + hint: 'Open a repository and star it to follow its activity here.', + }, + bounties: { + label: 'bounties', + path: '/bounties', + hint: 'Open a bounty and star it to track its submissions here.', + }, + prs: { + label: 'repositories', + path: '/repositories', + hint: 'Open a pull request and star it to monitor its scoring here.', + }, +}; + +const tabFromParam = (param: string | null): WatchlistCategory => + TAB_ORDER.includes(param as WatchlistCategory) + ? (param as WatchlistCategory) + : 'miners'; + +const WatchlistPage: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const activeTab = tabFromParam(searchParams.get('tab')); + + // Single subscription for tab badges; per-tab content uses useWatchlist + // scoped to its own category via the *List subcomponents below. + const counts = useWatchlistCounts(); + const { ids, count, clear } = useWatchlist(activeTab); + const [confirmOpen, setConfirmOpen] = useState(false); + + const isEmpty = count === 0; + const noun = TAB_NOUN[activeTab]; + const discovery = TAB_DISCOVERY[activeTab]; + + const isLargeScreen = useMediaQuery(theme.breakpoints.up('xl')); + const showSidebarRight = !isEmpty && isLargeScreen; + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + const isTablet = useMediaQuery(theme.breakpoints.between('sm', 'md')); + const sidebarWidth = + isMobile || isTablet ? '100%' : isLargeScreen ? '340px' : '300px'; + + const { ids: minerIds } = useWatchlist('miners'); + const { data: allMinersData } = useAllMiners(); + const minerStats = useMemo(() => { + const watchedSet = new Set(minerIds); + return mapAllMinersToStats(allMinersData ?? []) + .filter((m) => watchedSet.has(m.githubId)) + .map((m) => ({ + ...m, + isEligible: Boolean(m.ossIsEligible || m.discoveriesIsEligible), + })); + }, [allMinersData, minerIds]); + + const handleClear = () => { + clear(); + setConfirmOpen(false); + }; + + const handleTabChange = (_event: React.SyntheticEvent, next: unknown) => { + const validated = tabFromParam(String(next)); + setSearchParams( + (prev) => { + const params = new URLSearchParams(prev); + if (validated === 'miners') { + params.delete('tab'); + } else { + params.set('tab', validated); + } + return params; + }, + { replace: true }, + ); + }; + + return ( + + + + {/* Main Content Area */} + + + alpha(t.palette.text.primary, 0.5), + lineHeight: 1.6, + }} + > + Your watchlist — {count}{' '} + {count === 1 ? `${noun.single} pinned` : `${noun.plural} pinned`}. + Stored locally in this browser. + + {count > 0 && ( + + )} + + + + + {TAB_ORDER.map((cat) => ( + + 0 ? 1.5 : 0 }}> + {TAB_LABELS[cat]} + + + } + /> + ))} + + + + {isEmpty ? ( + + + No watched {noun.plural} yet. + + alpha(t.palette.text.primary, 0.5), + lineHeight: 1.6, + }} + > + {discovery.hint} Pinned items appear here across reloads and + tabs. + + + + ) : activeTab === 'miners' ? ( + + ) : activeTab === 'repos' ? ( + + ) : activeTab === 'bounties' ? ( + + ) : ( + + )} + + + {/* Right Sidebar — new activities */} + {!isEmpty && ( + + + + )} + + + setConfirmOpen(false)} + PaperProps={{ + sx: (t) => ({ + backgroundColor: t.palette.background.default, + border: `1px solid ${t.palette.border.light}`, + borderRadius: 3, + backgroundImage: 'none', + p: 3, + }), + }} + > + + Clear all {count} pinned {count === 1 ? noun.single : noun.plural}? + + + + + + + + ); +}; + +const rowSx = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 2, + px: 1.5, + py: 1.25, + borderRadius: 1, + transition: 'background 0.15s', + '&:hover': { backgroundColor: 'surface.light' }, +}; + +const primaryTextSx = { + fontSize: '0.85rem', + color: 'text.primary', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}; + +const secondaryTextSx = { + fontSize: '0.7rem', + color: 'text.secondary', + mt: 0.25, +}; + +interface WatchedItemRowProps { + href: string; + primary: React.ReactNode; + secondary?: React.ReactNode; + actions: React.ReactNode; +} + +// LinkBox wraps only the navigable text area (not the whole row) so the +// star button — a real